mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 18:58:59 -04:00
457 lines
18 KiB
C#
457 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
|
|
}, 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,
|
|
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
|
|
},
|
|
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
|
|
},
|
|
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);
|
|
}
|