Files
Cleanuparr/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs
T

461 lines
18 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
using Cleanuparr.Api.Features.Notifications.Descriptors;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Notifications.Controllers;
[ApiController]
[Route("api/configuration/notification_providers")]
[Authorize]
public sealed class NotificationProvidersController : ControllerBase
{
private const string ProviderNameRequiredMessage = "Provider name is required";
private const string DuplicateProviderNameMessage = "A provider with this name already exists";
private const string SensitiveFieldsPlaceholderMessage = "Sensitive fields cannot be placeholder values";
private const string TestNotificationSuccessMessage = "Test notification sent successfully";
private const string InvalidRequestBodyMessage = "Invalid request body";
private readonly ILogger<NotificationProvidersController> _logger;
private readonly DataContext _dataContext;
private readonly INotificationConfigurationService _notificationConfigurationService;
private readonly NotificationService _notificationService;
private readonly IAppriseCliDetector _appriseCliDetector;
private readonly TimeProvider _timeProvider;
private readonly INotificationProviderDescriptorRegistry _descriptorRegistry;
private readonly JsonSerializerOptions _jsonOptions;
public NotificationProvidersController(
ILogger<NotificationProvidersController> logger,
DataContext dataContext,
INotificationConfigurationService notificationConfigurationService,
NotificationService notificationService,
IAppriseCliDetector appriseCliDetector,
TimeProvider timeProvider,
INotificationProviderDescriptorRegistry descriptorRegistry,
JsonSerializerOptions jsonOptions)
{
_logger = logger;
_dataContext = dataContext;
_notificationConfigurationService = notificationConfigurationService;
_notificationService = notificationService;
_appriseCliDetector = appriseCliDetector;
_timeProvider = timeProvider;
_descriptorRegistry = descriptorRegistry;
_jsonOptions = jsonOptions;
}
[HttpGet]
public async Task<IActionResult> GetNotificationProviders()
{
await DataContext.Lock.WaitAsync();
try
{
List<NotificationConfig> providers = await IncludeAllConfigurations(_dataContext.NotificationConfigs)
.AsNoTracking()
.ToListAsync();
List<NotificationProviderResponse> providerDtos = providers
.Where(p => !EnumSentinel.IsUnknown(p.Type))
.Select(MapProvider)
.OrderBy(x => x.Type.ToString())
.ThenBy(x => x.Name)
.ToList();
NotificationProvidersResponse response = new() { Providers = providerDtos };
return Ok(response);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("apprise/cli-status")]
public async Task<IActionResult> GetAppriseCliStatus()
{
string? version = await _appriseCliDetector.GetAppriseVersionAsync();
return Ok(new
{
Available = version is not null,
Version = version
});
}
/// <summary>
/// Creates a notification provider of the given type.
/// </summary>
[HttpPost("{type}")]
public async Task<IActionResult> CreateProvider(NotificationProviderType type, [FromBody] JsonElement requestBody)
{
if (!TryGetDescriptor(type, out NotificationProviderDescriptor descriptor, out IActionResult? notSupported))
{
return notSupported!;
}
if (!TryReadRequest(requestBody, descriptor.CreateRequestType, out CreateNotificationProviderRequestBase? request, out IActionResult? invalidBody))
{
return invalidBody!;
}
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return this.ProblemResult(StatusCodes.Status400BadRequest, ProviderNameRequiredMessage);
}
int duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == request.Name);
if (duplicateConfig > 0)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, DuplicateProviderNameMessage);
}
NotificationProviderSensitiveField? placeholderField = descriptor.SensitiveFields
.FirstOrDefault(field => GetFieldValue(request, field.Name).IsPlaceholder());
if (placeholderField is not null)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, placeholderField.PlaceholderErrorMessage);
}
IConfig config = descriptor.BuildConfig(request);
config.Validate();
NotificationConfig provider = descriptor.WithConfig(new NotificationConfig
{
Name = request.Name,
Type = type,
IsEnabled = request.IsEnabled,
OnFailedImportStrike = request.OnFailedImportStrike,
OnStalledStrike = request.OnStalledStrike,
OnSlowStrike = request.OnSlowStrike,
OnQueueItemDeleted = request.OnQueueItemDeleted,
OnDownloadCleaned = request.OnDownloadCleaned,
OnDownloadStopped = request.OnDownloadStopped,
OnCategoryChanged = request.OnCategoryChanged,
OnSearchTriggered = request.OnSearchTriggered,
OnSearchItemGrabbed = request.OnSearchItemGrabbed,
OnForceImported = request.OnForceImported,
OnErroredStrike = request.OnErroredStrike,
OnSeekerCycleCompleted = request.OnSeekerCycleCompleted
}, config);
await _dataContext.NotificationConfigs.AddAsync(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
NotificationProviderResponse providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
finally
{
DataContext.Lock.Release();
}
}
/// <summary>
/// Updates a notification provider of the given type.
/// </summary>
[HttpPut("{type}/{id:guid}")]
public async Task<IActionResult> UpdateProvider(NotificationProviderType type, Guid id, [FromBody] JsonElement requestBody)
{
if (!TryGetDescriptor(type, out NotificationProviderDescriptor descriptor, out IActionResult? notSupported))
{
return notSupported!;
}
if (!TryReadRequest(requestBody, descriptor.UpdateRequestType, out UpdateNotificationProviderRequestBase? request, out IActionResult? invalidBody))
{
return invalidBody!;
}
await DataContext.Lock.WaitAsync();
try
{
NotificationConfig? existingProvider = await IncludeAllConfigurations(_dataContext.NotificationConfigs)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == type);
if (existingProvider is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return this.ProblemResult(StatusCodes.Status400BadRequest, ProviderNameRequiredMessage);
}
int duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == request.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, DuplicateProviderNameMessage);
}
IConfig? existingConfig = descriptor.GetConfig(existingProvider);
IConfig newConfig = descriptor.BuildUpdateConfig(request, existingConfig);
newConfig.Validate();
NotificationConfig newProvider = descriptor.WithConfig(existingProvider with
{
Name = request.Name,
IsEnabled = request.IsEnabled,
OnFailedImportStrike = request.OnFailedImportStrike,
OnStalledStrike = request.OnStalledStrike,
OnSlowStrike = request.OnSlowStrike,
OnQueueItemDeleted = request.OnQueueItemDeleted,
OnDownloadCleaned = request.OnDownloadCleaned,
OnDownloadStopped = request.OnDownloadStopped,
OnCategoryChanged = request.OnCategoryChanged,
OnSearchTriggered = request.OnSearchTriggered,
OnSearchItemGrabbed = request.OnSearchItemGrabbed,
OnForceImported = request.OnForceImported,
OnErroredStrike = request.OnErroredStrike,
OnSeekerCycleCompleted = request.OnSeekerCycleCompleted,
UpdatedAt = _timeProvider.GetUtcNow()
}, newConfig);
_dataContext.NotificationConfigs.Remove(existingProvider);
await _dataContext.NotificationConfigs.AddAsync(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
NotificationProviderResponse providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
NotificationConfig? existingProvider = await IncludeAllConfigurations(_dataContext.NotificationConfigs)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Notification provider with ID {id} not found");
}
_dataContext.NotificationConfigs.Remove(existingProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
_logger.LogInformation("Removed notification provider {ProviderName} with ID {ProviderId}",
existingProvider.Name, existingProvider.Id);
return NoContent();
}
finally
{
DataContext.Lock.Release();
}
}
/// <summary>
/// Sends a test notification for the given provider type, without persisting anything.
/// </summary>
[HttpPost("{type}/test")]
public async Task<IActionResult> TestProvider(NotificationProviderType type, [FromBody] JsonElement requestBody)
{
if (!TryGetDescriptor(type, out NotificationProviderDescriptor descriptor, out IActionResult? notSupported))
{
return notSupported!;
}
if (!TryReadRequest(requestBody, descriptor.TestRequestType, out TestNotificationProviderRequestBase? request, out IActionResult? invalidBody))
{
return invalidBody!;
}
try
{
bool needsExisting = descriptor.SensitiveFields.Any(field => GetFieldValue(request, field.Name).IsPlaceholder());
IConfig? existingConfig = null;
if (needsExisting)
{
existingConfig = await GetExistingProviderConfig(request.ProviderId, type);
if (existingConfig is null)
{
string message = descriptor.SensitiveFields.Count == 1
? descriptor.SensitiveFields[0].PlaceholderErrorMessage
: SensitiveFieldsPlaceholderMessage;
return this.ProblemResult(StatusCodes.Status400BadRequest, message);
}
}
IConfig config = descriptor.BuildTestConfig(request, existingConfig);
config.Validate();
NotificationProviderDto providerDto = new()
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = type,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnDownloadStopped = false,
OnCategoryChanged = false,
OnSearchTriggered = false,
OnSearchItemGrabbed = false,
OnForceImported = false,
OnErroredStrike = false,
OnSeekerCycleCompleted = false
},
Configuration = config
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = TestNotificationSuccessMessage });
}
catch (Exception ex)
{
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
private NotificationProviderResponse MapProvider(NotificationConfig provider)
{
NotificationProviderDescriptor descriptor = _descriptorRegistry.GetDescriptor(provider.Type);
return new NotificationProviderResponse
{
Id = provider.Id,
Name = provider.Name,
Type = provider.Type,
IsEnabled = provider.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = provider.OnFailedImportStrike,
OnStalledStrike = provider.OnStalledStrike,
OnSlowStrike = provider.OnSlowStrike,
OnQueueItemDeleted = provider.OnQueueItemDeleted,
OnDownloadCleaned = provider.OnDownloadCleaned,
OnDownloadStopped = provider.OnDownloadStopped,
OnCategoryChanged = provider.OnCategoryChanged,
OnSearchTriggered = provider.OnSearchTriggered,
OnSearchItemGrabbed = provider.OnSearchItemGrabbed,
OnForceImported = provider.OnForceImported,
OnErroredStrike = provider.OnErroredStrike,
OnSeekerCycleCompleted = provider.OnSeekerCycleCompleted
},
Configuration = (object?)descriptor.GetConfig(provider) ?? new object()
};
}
private bool TryGetDescriptor(
NotificationProviderType type,
out NotificationProviderDescriptor descriptor,
out IActionResult? notSupportedResult)
{
try
{
descriptor = _descriptorRegistry.GetDescriptor(type);
notSupportedResult = null;
return true;
}
catch (NotSupportedException)
{
descriptor = null!;
notSupportedResult = this.ProblemResult(
StatusCodes.Status404NotFound, $"Notification provider type {type} is not supported");
return false;
}
}
/// <summary>
/// Deserializes the request body into the given type, returning a 400 problem result instead of throwing on a null or wrong-typed body.
/// </summary>
private bool TryReadRequest<TRequest>(
JsonElement body,
Type requestType,
[NotNullWhen(true)] out TRequest? request,
out IActionResult? error)
where TRequest : class
{
request = null;
error = null;
if (body.ValueKind == JsonValueKind.Object)
{
try
{
request = body.Deserialize(requestType, _jsonOptions) as TRequest;
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
// Falls through to the 400 below.
}
}
if (request is null)
{
error = this.ProblemResult(StatusCodes.Status400BadRequest, InvalidRequestBodyMessage);
return false;
}
return true;
}
private async Task<IConfig?> GetExistingProviderConfig(Guid? providerId, NotificationProviderType type)
{
if (!providerId.HasValue)
{
return null;
}
NotificationConfig? provider = await IncludeAllConfigurations(_dataContext.NotificationConfigs)
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == providerId.Value && p.Type == type);
return provider is null ? null : _descriptorRegistry.GetDescriptor(type).GetConfig(provider);
}
private static string? GetFieldValue(object request, string propertyName) =>
(string?)request.GetType().GetProperty(propertyName)!.GetValue(request);
private static IQueryable<NotificationConfig> IncludeAllConfigurations(IQueryable<NotificationConfig> query) =>
query
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration);
}