Use ProblemDetails handling for the API (#632)

This commit is contained in:
Flaminel
2026-06-16 15:26:56 +03:00
committed by GitHub
parent 74f11f5beb
commit c6ef6ad979
34 changed files with 790 additions and 1044 deletions

View File

@@ -87,7 +87,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
body.GetProperty("detail").GetString().ShouldContain("OIDC is not enabled");
}
[Fact, TestPriority(3)]

View File

@@ -67,9 +67,11 @@ public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.Oid
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json");
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
body.GetProperty("detail").GetString()!.ShouldContain("OIDC is not enabled");
body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(3)]

View File

@@ -2,9 +2,11 @@ using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -24,6 +26,7 @@ public class DeadTorrentConfigControllerTests : IDisposable
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<DeadTorrentConfigController>>();
_controller = new DeadTorrentConfigController(logger, _dataContext);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
@@ -90,7 +93,7 @@ public class DeadTorrentConfigControllerTests : IDisposable
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest());
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
@@ -98,6 +101,6 @@ public class DeadTorrentConfigControllerTests : IDisposable
{
var result = await _controller.UpdateDeadTorrentConfig(Guid.NewGuid(), ValidRequest());
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
}

View File

@@ -2,9 +2,12 @@ using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
@@ -22,6 +25,7 @@ public class SeedingRulesControllerTests : IDisposable
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<SeedingRulesController>>();
_controller = new SeedingRulesController(logger, _dataContext);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
@@ -108,7 +112,7 @@ public class SeedingRulesControllerTests : IDisposable
public async Task GetSeedingRules_NonExistentClient_ReturnsNotFound()
{
var result = await _controller.GetSeedingRules(Guid.NewGuid());
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
@@ -214,7 +218,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = CreateValidRequest(priority: 1);
var result = await _controller.CreateSeedingRule(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
@@ -223,7 +227,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = CreateValidRequest();
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
@@ -232,10 +236,7 @@ public class SeedingRulesControllerTests : IDisposable
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(categories: []);
var result = await _controller.CreateSeedingRule(client.Id, request);
// Validate() throws ValidationException → caught → BadRequest
result.ShouldBeOfType<BadRequestObjectResult>();
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
}
[Fact]
@@ -335,7 +336,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = CreateValidRequest();
var result = await _controller.UpdateSeedingRule(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
@@ -347,8 +348,7 @@ public class SeedingRulesControllerTests : IDisposable
// Both maxRatio and maxSeedTime negative → validation failure
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
}
// ──────────────────────────────────────────────────────────────────────
@@ -396,7 +396,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
@@ -409,7 +409,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
@@ -423,7 +423,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
@@ -436,7 +436,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
// ──────────────────────────────────────────────────────────────────────
@@ -468,6 +468,6 @@ public class SeedingRulesControllerTests : IDisposable
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
{
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
}

View File

@@ -0,0 +1,108 @@
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Api.Middleware;
using Cleanuparr.Domain.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace Cleanuparr.Api.Tests.Middleware;
public class GlobalExceptionHandlerTests
{
private static readonly ProblemDetailsFactory ProblemDetailsFactory = BuildProblemDetailsFactory();
private static ProblemDetailsFactory BuildProblemDetailsFactory()
{
ServiceCollection services = new();
services.AddLogging();
services.AddControllers();
services.AddCleanuparrProblemDetails();
return services.BuildServiceProvider().GetRequiredService<ProblemDetailsFactory>();
}
private static async Task<(bool handled, HttpContext context, ProblemDetails problemDetails)> Handle(Exception exception)
{
IProblemDetailsService problemDetailsService = Substitute.For<IProblemDetailsService>();
problemDetailsService
.TryWriteAsync(Arg.Any<ProblemDetailsContext>())
.Returns(callInfo => ValueTask.FromResult(true));
DefaultHttpContext context = new();
GlobalExceptionHandler handler = new(problemDetailsService, ProblemDetailsFactory, NullLogger<GlobalExceptionHandler>.Instance);
bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None);
ProblemDetailsContext captured = (ProblemDetailsContext)problemDetailsService
.ReceivedCalls()
.Single()
.GetArguments()[0]!;
return (handled, context, captured.ProblemDetails);
}
[Fact]
public async Task ValidationException_MapsTo400_WithMessageAsDetail()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new ValidationException("Name is required"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Title.ShouldBe("Validation failed");
problemDetails.Detail.ShouldBe("Name is required");
problemDetails.Type.ShouldNotBeNullOrEmpty();
problemDetails.Extensions.ShouldContainKey("traceId");
}
[Fact]
public async Task NotificationTestException_MapsTo400()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new NotificationTestException("Test failed: connection refused"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Title.ShouldBe("Notification test failed");
problemDetails.Detail.ShouldBe("Test failed: connection refused");
}
[Fact]
public async Task RateLimitException_MapsTo429_WithRetryAfterExtensionAndHeader()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Account is locked", 30));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Status.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Title.ShouldBe("Too many requests");
problemDetails.Extensions["retryAfterSeconds"].ShouldBe(30);
context.Response.Headers.RetryAfter.ToString().ShouldBe("30");
}
[Fact]
public async Task RateLimitException_WithZeroRetry_MapsTo429_WithoutRetryAfter()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Too many pending OIDC flows", 0));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Extensions.ShouldNotContainKey("retryAfterSeconds");
context.Response.Headers.RetryAfter.ToString().ShouldBeEmpty();
}
[Fact]
public async Task UnknownException_MapsTo500_WithGenericDetail_AndDoesNotLeakMessage()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new InvalidOperationException("internal connection string leaked"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError);
problemDetails.Status.ShouldBe(StatusCodes.Status500InternalServerError);
problemDetails.Detail.ShouldBe("An unexpected error occurred");
problemDetails.Detail.ShouldNotContain("connection string");
}
}

View File

@@ -0,0 +1,35 @@
using Cleanuparr.Api.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace Cleanuparr.Api.Tests.TestHelpers;
/// <summary>
/// Attaches a minimal MVC <see cref="ControllerContext"/> (with a real <see cref="ProblemDetailsFactory"/>
/// and <see cref="HttpContext"/>) to a directly-instantiated controller so that
/// <c>this.ProblemResult(...)</c> can build problem-details responses in unit tests.
/// </summary>
public static class ControllerTestContext
{
private static readonly IServiceProvider Services = BuildServices();
private static IServiceProvider BuildServices()
{
ServiceCollection services = new();
services.AddLogging();
services.AddControllers();
services.AddCleanuparrProblemDetails();
return services.BuildServiceProvider();
}
public static void Attach(ControllerBase controller)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { RequestServices = Services },
};
controller.ProblemDetailsFactory = Services.GetRequiredService<ProblemDetailsFactory>();
}
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Infrastructure.Health;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,17 +13,13 @@ namespace Cleanuparr.Api.Controllers;
[Authorize]
public class HealthCheckController : ControllerBase
{
private readonly ILogger<HealthCheckController> _logger;
private readonly IHealthCheckService _healthCheckService;
/// <summary>
/// Initializes a new instance of the <see cref="HealthCheckController"/> class
/// </summary>
public HealthCheckController(
ILogger<HealthCheckController> logger,
IHealthCheckService healthCheckService)
public HealthCheckController(IHealthCheckService healthCheckService)
{
_logger = logger;
_healthCheckService = healthCheckService;
}
@@ -32,16 +29,8 @@ public class HealthCheckController : ControllerBase
[HttpGet]
public IActionResult GetAllHealth()
{
try
{
var healthStatuses = _healthCheckService.GetAllClientHealth();
return Ok(healthStatuses);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving client health statuses");
return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" });
}
var healthStatuses = _healthCheckService.GetAllClientHealth();
return Ok(healthStatuses);
}
/// <summary>
@@ -50,21 +39,13 @@ public class HealthCheckController : ControllerBase
[HttpGet("{id:guid}")]
public IActionResult GetClientHealth(Guid id)
{
try
var healthStatus = _healthCheckService.GetClientHealth(id);
if (healthStatus == null)
{
var healthStatus = _healthCheckService.GetClientHealth(id);
if (healthStatus == null)
{
return NotFound(new { Message = $"Health status for client with ID '{id}' not found" });
}
return this.ProblemResult(StatusCodes.Status404NotFound, $"Health status for client with ID '{id}' not found");
}
return Ok(healthStatus);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving health status for client {id}", id);
return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" });
}
return Ok(healthStatus);
}
/// <summary>
@@ -73,16 +54,8 @@ public class HealthCheckController : ControllerBase
[HttpPost("check")]
public async Task<IActionResult> CheckAllHealth()
{
try
{
var results = await _healthCheckService.CheckAllClientsHealthAsync();
return Ok(results);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking health for all clients");
return StatusCode(500, new { Error = "An error occurred while checking client health" });
}
var results = await _healthCheckService.CheckAllClientsHealthAsync();
return Ok(results);
}
/// <summary>
@@ -91,15 +64,7 @@ public class HealthCheckController : ControllerBase
[HttpPost("check/{id:guid}")]
public async Task<IActionResult> CheckClientHealth(Guid id)
{
try
{
var result = await _healthCheckService.CheckClientHealthAsync(id);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking health for client {id}", id);
return StatusCode(500, new { Error = "An error occurred while checking client health" });
}
var result = await _healthCheckService.CheckClientHealthAsync(id);
return Ok(result);
}
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
@@ -13,47 +14,29 @@ namespace Cleanuparr.Api.Controllers;
public class JobsController : ControllerBase
{
private readonly IJobManagementService _jobManagementService;
private readonly ILogger<JobsController> _logger;
public JobsController(IJobManagementService jobManagementService, ILogger<JobsController> logger)
public JobsController(IJobManagementService jobManagementService)
{
_jobManagementService = jobManagementService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetAllJobs()
{
try
{
var result = await _jobManagementService.GetAllJobs();
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting all jobs");
return StatusCode(500, "An error occurred while retrieving jobs");
}
var result = await _jobManagementService.GetAllJobs();
return Ok(result);
}
[HttpGet("{jobType}")]
public async Task<IActionResult> GetJob(JobType jobType)
{
try
var jobInfo = await _jobManagementService.GetJob(jobType);
if (jobInfo.Status == "Not Found")
{
var jobInfo = await _jobManagementService.GetJob(jobType);
if (jobInfo.Status == "Not Found")
{
return NotFound($"Job '{jobType}' not found");
}
return Ok(jobInfo);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting job {jobType}", jobType);
return StatusCode(500, $"An error occurred while retrieving job '{jobType}'");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Job '{jobType}' not found");
}
return Ok(jobInfo);
}
[HttpPost("{jobType}/start")]
@@ -61,27 +44,19 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually controlled");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually controlled");
}
try
// Get the schedule from the request body if provided
JobSchedule jobSchedule = scheduleRequest.Schedule;
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
if (!result)
{
// Get the schedule from the request body if provided
JobSchedule jobSchedule = scheduleRequest.Schedule;
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
if (!result)
{
return BadRequest($"Failed to start job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' started successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting job {jobType}", jobType);
return StatusCode(500, $"An error occurred while starting job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to start job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' started successfully" });
}
[HttpPost("{jobType}/trigger")]
@@ -89,24 +64,16 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually triggered");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually triggered");
}
try
var result = await _jobManagementService.TriggerJobOnce(jobType);
if (!result)
{
var result = await _jobManagementService.TriggerJobOnce(jobType);
if (!result)
{
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
}
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to trigger job '{jobType}' - job may not exist or be configured");
}
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
}
[HttpPut("{jobType}/schedule")]
@@ -114,28 +81,20 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job schedule cannot be manually modified");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job schedule cannot be manually modified");
}
if (scheduleRequest?.Schedule == null)
{
return BadRequest("Schedule is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Schedule is required");
}
try
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
if (!result)
{
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
if (!result)
{
return BadRequest($"Failed to update schedule for job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job {jobType} schedule", jobType);
return StatusCode(500, $"An error occurred while updating schedule for job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to update schedule for job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
}
}

View File

@@ -12,14 +12,10 @@ namespace Cleanuparr.Api.Controllers;
[Authorize]
public class StatsController : ControllerBase
{
private readonly ILogger<StatsController> _logger;
private readonly IStatsService _statsService;
public StatsController(
ILogger<StatsController> logger,
IStatsService statsService)
public StatsController(IStatsService statsService)
{
_logger = logger;
_statsService = statsService;
}
@@ -35,19 +31,11 @@ public class StatsController : ControllerBase
[FromQuery] int includeEvents = 0,
[FromQuery] int includeStrikes = 0)
{
try
{
hours = Math.Clamp(hours, 1, 720);
includeEvents = Math.Clamp(includeEvents, 0, 100);
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
hours = Math.Clamp(hours, 1, 720);
includeEvents = Math.Clamp(includeEvents, 0, 100);
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
return Ok(stats);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving stats");
return StatusCode(500, new { Error = "An error occurred while retrieving stats" });
}
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
return Ok(stats);
}
}

View File

@@ -13,16 +13,13 @@ namespace Cleanuparr.Api.Controllers;
[Authorize]
public class StatusController : ControllerBase
{
private readonly ILogger<StatusController> _logger;
private readonly DataContext _dataContext;
private readonly IArrClientFactory _arrClientFactory;
public StatusController(
ILogger<StatusController> logger,
DataContext dataContext,
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
@@ -30,247 +27,219 @@ public class StatusController : ControllerBase
[HttpGet]
public async Task<IActionResult> GetSystemStatus()
{
try
{
var process = Process.GetCurrentProcess();
// Get configuration
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var sonarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Sonarr);
var radarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Radarr);
var lidarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
Application = new
{
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
process.StartTime,
UpTime = DateTimeOffset.UtcNow - process.StartTime.ToUniversalTime(),
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
ProcessorTime = process.TotalProcessorTime
},
DownloadClient = new
{
// TODO
},
MediaManagers = new
{
Sonarr = new
{
InstanceCount = sonarrConfig.Instances.Count
},
Radarr = new
{
InstanceCount = radarrConfig.Instances.Count
},
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};
using var process = Process.GetCurrentProcess();
return Ok(status);
}
catch (Exception ex)
// Get configuration
var sonarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Sonarr);
var radarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Radarr);
var lidarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
_logger.LogError(ex, "Error retrieving system status");
return StatusCode(500, "An error occurred while retrieving system status");
}
Application = new
{
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
process.StartTime,
UpTime = DateTimeOffset.UtcNow - process.StartTime.ToUniversalTime(),
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
ProcessorTime = process.TotalProcessorTime
},
DownloadClient = new
{
// TODO
},
MediaManagers = new
{
Sonarr = new
{
InstanceCount = sonarrConfig.Instances.Count
},
Radarr = new
{
InstanceCount = radarrConfig.Instances.Count
},
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};
return Ok(status);
}
[HttpGet("download-client")]
public async Task<IActionResult> GetDownloadClientStatus()
{
try
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var result = new Dictionary<string, object>();
// Check for configured clients
if (downloadClients.Count > 0)
{
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var result = new Dictionary<string, object>();
// Check for configured clients
if (downloadClients.Count > 0)
var clientsStatus = new List<object>();
foreach (var client in downloadClients)
{
var clientsStatus = new List<object>();
foreach (var client in downloadClients)
clientsStatus.Add(new
{
clientsStatus.Add(new
{
client.Id,
client.Name,
Type = client.TypeName,
client.Host,
client.Enabled,
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
});
}
result["Clients"] = clientsStatus;
client.Id,
client.Name,
Type = client.TypeName,
client.Host,
client.Enabled,
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
});
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving download client status");
return StatusCode(500, "An error occurred while retrieving download client status");
result["Clients"] = clientsStatus;
}
return Ok(result);
}
[HttpGet("arrs")]
public async Task<IActionResult> GetMediaManagersStatus()
{
try
var status = new Dictionary<string, object>();
// Get configurations
var enabledSonarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Sonarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledRadarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Radarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledLidarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Lidarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
// Check Sonarr instances
var sonarrStatus = new List<object>();
foreach (var instance in enabledSonarrInstances)
{
var status = new Dictionary<string, object>();
// Get configurations
var enabledSonarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Sonarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledRadarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Radarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledLidarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Lidarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();;
// Check Sonarr instances
var sonarrStatus = new List<object>();
foreach (var instance in enabledSonarrInstances)
try
{
try
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
sonarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
status["Sonarr"] = sonarrStatus;
// Check Radarr instances
var radarrStatus = new List<object>();
foreach (var instance in enabledRadarrInstances)
catch (Exception ex)
{
try
sonarrStatus.Add(new
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
status["Radarr"] = radarrStatus;
// Check Lidarr instances
var lidarrStatus = new List<object>();
foreach (var instance in enabledLidarrInstances)
{
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Lidarr"] = lidarrStatus;
return Ok(status);
}
catch (Exception ex)
status["Sonarr"] = sonarrStatus;
// Check Radarr instances
var radarrStatus = new List<object>();
foreach (var instance in enabledRadarrInstances)
{
_logger.LogError(ex, "Error retrieving media managers status");
return StatusCode(500, "An error occurred while retrieving media managers status");
try
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Radarr"] = radarrStatus;
// Check Lidarr instances
var lidarrStatus = new List<object>();
foreach (var instance in enabledLidarrInstances)
{
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Lidarr"] = lidarrStatus;
return Ok(status);
}
}

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Cleanuparr.Api.Filters;
@@ -55,24 +56,42 @@ public static class ApiDI
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
services.AddCleanuparrProblemDetails();
services.AddExceptionHandler<GlobalExceptionHandler>();
return services;
}
/// <summary>
/// Registers RFC 9457 problem-details responses for both the exception handler and
/// [ApiController] model-state validation, attaching a uniform Activity-tied traceId.
/// </summary>
public static IServiceCollection AddCleanuparrProblemDetails(this IServiceCollection services)
{
services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions.TryAdd(
"traceId", Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
});
return services;
}
public static WebApplication ConfigureApi(this WebApplication app)
{
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
// Map unhandled exceptions to RFC 9457 problem-details responses (GlobalExceptionHandler).
// Registered first so it also covers exceptions thrown by downstream middleware.
app.UseExceptionHandler();
// Enable compression
app.UseResponseCompression();
// Serve static files without caching
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx => NoCacheAttribute.Apply(ctx.Context.Response.Headers)
});
// Add the global exception handling middleware first
app.UseMiddleware<ExceptionMiddleware>();
// Resolve the real client IP / scheme / host from X-Forwarded-* headers
app.UseMiddleware<TrustedForwardedHeadersMiddleware>();

View File

@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Extensions;
public static class ControllerBaseExtensions
{
/// <summary>
/// Builds an RFC 9457 problem-details error response for a direct (non-throwing) controller return.
/// Mirrors the shape produced by <see cref="Middleware.GlobalExceptionHandler"/> so every error
/// response carries the same <c>application/problem+json</c> body and <c>traceId</c>. The
/// <c>traceId</c> extension is added by the shared <c>CustomizeProblemDetails</c> hook inside
/// <see cref="ProblemDetailsFactory.CreateProblemDetails"/>.
/// </summary>
public static ObjectResult ProblemResult(
this ControllerBase controller,
int statusCode,
string detail,
string? title = null,
IReadOnlyDictionary<string, object?>? extensions = null)
{
ProblemDetails problemDetails = controller.ProblemDetailsFactory
.CreateProblemDetails(controller.HttpContext, statusCode: statusCode, title: title, detail: detail);
if (extensions is not null)
{
foreach (KeyValuePair<string, object?> extension in extensions)
{
problemDetails.Extensions[extension.Key] = extension.Value;
}
}
return new ObjectResult(problemDetails)
{
StatusCode = statusCode,
ContentTypes = { "application/problem+json" },
};
}
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
@@ -182,11 +183,6 @@ public sealed class ArrConfigController : ControllerBase
return Ok(new { Message = $"{type} configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save {Type} configuration", type);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -207,11 +203,6 @@ public sealed class ArrConfigController : ControllerBase
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create {Type} instance", type);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -230,7 +221,7 @@ public sealed class ArrConfigController : ControllerBase
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
}
request.ApplyTo(instance);
@@ -239,11 +230,6 @@ public sealed class ArrConfigController : ControllerBase
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -262,7 +248,7 @@ public sealed class ArrConfigController : ControllerBase
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
}
config.Instances.Remove(instance);
@@ -270,11 +256,6 @@ public sealed class ArrConfigController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -295,7 +276,7 @@ public sealed class ArrConfigController : ControllerBase
if (existingInstance is null)
{
return NotFound($"Instance with ID {request.InstanceId.Value} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Instance with ID {request.InstanceId.Value} not found");
}
resolvedApiKey = existingInstance.ApiKey;
@@ -310,7 +291,7 @@ public sealed class ArrConfigController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}");
}
}

View File

@@ -4,13 +4,13 @@ using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.Auth.Controllers;
@@ -67,7 +67,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Password changes are disabled while OIDC exclusive mode is active.");
}
var user = await GetCurrentUser();
@@ -78,7 +78,7 @@ public sealed class AccountController : ControllerBase
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
{
return BadRequest(new { error = "Current password is incorrect" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Current password is incorrect");
}
DateTimeOffset now = DateTimeOffset.UtcNow;
@@ -115,12 +115,12 @@ public sealed class AccountController : ControllerBase
// Verify current credentials
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid 2FA code");
}
// Generate new TOTP
@@ -168,12 +168,12 @@ public sealed class AccountController : ControllerBase
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
// Generate new TOTP
@@ -221,17 +221,17 @@ public sealed class AccountController : ControllerBase
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return BadRequest(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid verification code");
}
user.TotpEnabled = true;
@@ -254,17 +254,17 @@ public sealed class AccountController : ControllerBase
if (!user.TotpEnabled)
{
return BadRequest(new { error = "2FA is not enabled" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "2FA is not enabled");
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid 2FA code");
}
user.TotpEnabled = false;
@@ -320,7 +320,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var pin = await _plexAuthService.RequestPin();
@@ -333,7 +333,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
@@ -369,7 +369,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var user = await GetCurrentUser();
@@ -405,25 +405,18 @@ public sealed class AccountController : ControllerBase
[HttpPut("oidc")]
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
{
try
var user = await GetCurrentUser();
if (user is null)
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
request.ApplyTo(user.Oidc);
user.Oidc.Validate();
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
return Ok(new { message = "OIDC configuration updated" });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
return Unauthorized();
}
request.ApplyTo(user.Oidc);
user.Oidc.Validate();
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
return Ok(new { message = "OIDC configuration updated" });
}
[HttpPost("oidc/link")]
@@ -437,7 +430,7 @@ public sealed class AccountController : ControllerBase
if (user.Oidc is not { Enabled: true })
{
return BadRequest(new { error = "OIDC is not enabled" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "OIDC is not enabled");
}
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
@@ -450,8 +443,7 @@ public sealed class AccountController : ControllerBase
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
return StatusCode(429, new { error = ex.Message });
throw new RateLimitException(ex.Message, ex);
}
}
@@ -556,7 +548,7 @@ public sealed class AccountController : ControllerBase
{
if (request.FeatureIds.Count > MaxFeatureIdsPerRequest)
{
return BadRequest(new { error = $"featureIds exceeds the maximum allowed ({MaxFeatureIdsPerRequest})." });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"featureIds exceeds the maximum allowed ({MaxFeatureIdsPerRequest}).");
}
await UsersContext.Lock.WaitAsync();

View File

@@ -4,6 +4,7 @@ using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
@@ -92,7 +93,7 @@ public sealed class AuthController : ControllerBase
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
if (existingUser is not null)
{
return Conflict(new { error = "Account already exists" });
return this.ProblemResult(StatusCodes.Status409Conflict, "Account already exists");
}
var user = new User
@@ -133,12 +134,12 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
}
// Generate new TOTP secret
@@ -190,22 +191,22 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return Unauthorized(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code");
}
user.TotpEnabled = true;
@@ -231,12 +232,12 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed" });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed");
}
user.SetupCompleted = true;
@@ -258,7 +259,7 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Login with credentials is disabled. Use OIDC to sign in.");
}
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
@@ -270,20 +271,21 @@ public sealed class AuthController : ControllerBase
if (user is null || !user.SetupCompleted)
{
return Unauthorized(new { error = "Invalid credentials" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials");
}
// Check lockout
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTimeOffset.UtcNow)
{
var remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTimeOffset.UtcNow).TotalSeconds);
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
int remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTimeOffset.UtcNow).TotalSeconds);
throw new RateLimitException("Account is locked", remaining);
}
if (!passwordValid || !string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
{
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
int retryAfterSeconds = await IncrementFailedAttempts(user.Id);
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials",
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
}
// Reset failed attempts on successful password verification
@@ -320,13 +322,13 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Login with credentials is disabled. Use OIDC to sign in.");
}
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
if (userId is null)
{
return Unauthorized(new { error = "Invalid or expired login token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired login token");
}
var user = await _usersContext.Users
@@ -335,7 +337,7 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return Unauthorized(new { error = "Invalid login token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid login token");
}
bool codeValid;
@@ -351,7 +353,7 @@ public sealed class AuthController : ControllerBase
if (!codeValid)
{
return Unauthorized(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code");
}
return Ok(await GenerateTokenResponse(user));
@@ -371,7 +373,7 @@ public sealed class AuthController : ControllerBase
if (storedToken is null || storedToken.ExpiresAt < DateTimeOffset.UtcNow)
{
return Unauthorized(new { error = "Invalid or expired refresh token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired refresh token");
}
// Revoke the old token (rotation)
@@ -420,12 +422,12 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage Plex.");
}
var pin = await _plexAuthService.RequestPin();
@@ -455,12 +457,12 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage Plex.");
}
user.PlexAccountId = plexAccount.AccountId;
@@ -486,13 +488,13 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex login is disabled. Use OIDC to sign in.");
}
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
{
return BadRequest(new { error = "Plex login is not available" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
var pin = await _plexAuthService.RequestPin();
@@ -509,13 +511,13 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex login is disabled. Use OIDC to sign in.");
}
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
{
return BadRequest(new { error = "Plex login is not available" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
@@ -530,7 +532,7 @@ public sealed class AuthController : ControllerBase
if (plexAccount.AccountId != user.PlexAccountId)
{
return Unauthorized(new { error = "Plex account does not match the linked account" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Plex account does not match the linked account");
}
// Plex OAuth acts as a trusted identity provider — the user explicitly linked their
@@ -558,7 +560,7 @@ public sealed class AuthController : ControllerBase
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
string.IsNullOrEmpty(oidcConfig.ClientId))
{
return BadRequest(new { error = "OIDC is not enabled or not configured" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "OIDC is not enabled or not configured");
}
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
@@ -571,8 +573,7 @@ public sealed class AuthController : ControllerBase
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC authorization");
return StatusCode(429, new { error = ex.Message });
throw new RateLimitException(ex.Message, ex);
}
}
@@ -642,7 +643,7 @@ public sealed class AuthController : ControllerBase
if (result is null)
{
return NotFound(new { error = "Invalid or expired code" });
return this.ProblemResult(StatusCodes.Status404NotFound, "Invalid or expired code");
}
return Ok(new TokenResponse

View File

@@ -89,11 +89,6 @@ public sealed class BlacklistSyncConfigController : ControllerBase
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
throw;
}
finally
{
DataContext.Lock.Release();

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
@@ -38,7 +39,7 @@ public class DeadTorrentConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.DeadTorrentConfigs
@@ -56,11 +57,6 @@ public class DeadTorrentConfigController : ControllerBase
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateDeadTorrentConfig(Guid downloadClientId, [FromBody] DeadTorrentConfigRequest dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -70,12 +66,12 @@ public class DeadTorrentConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
if (dto.Enabled && client.TypeName is DownloadClientTypeName.rTorrent)
{
return BadRequest(new { Message = "Dead torrent handling is not supported for rTorrent (no seeder count available)" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Dead torrent handling is not supported for rTorrent (no seeder count available)");
}
var existing = await _dataContext.DeadTorrentConfigs

View File

@@ -136,15 +136,6 @@ public sealed class DownloadCleanerConfigController : ControllerBase
return Ok(new { Message = "DownloadCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save DownloadCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Persistence;
@@ -36,7 +37,7 @@ public sealed class OrphanedFilesConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.OrphanedFilesConfigs
@@ -54,11 +55,6 @@ public sealed class OrphanedFilesConfigController : ControllerBase
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateClientConfig(Guid downloadClientId, [FromBody] OrphanedFilesConfigRequest dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -68,7 +64,7 @@ public sealed class OrphanedFilesConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existing = await _dataContext.OrphanedFilesConfigs

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
@@ -8,7 +9,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
@@ -40,18 +40,13 @@ public class SeedingRulesController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var rules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
return Ok(rules.Select(SeedingRuleResponse.From));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve seeding rules for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to retrieve seeding rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -61,11 +56,6 @@ public class SeedingRulesController : ControllerBase
[HttpPost("{downloadClientId}")]
public async Task<IActionResult> CreateSeedingRule(Guid downloadClientId, [FromBody] SeedingRuleRequest ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -75,14 +65,14 @@ public class SeedingRulesController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existingRules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
if (ruleDto.Priority.HasValue && existingRules.Any(r => r.Priority == ruleDto.Priority.Value))
{
return BadRequest(new { Message = $"A seeding rule with priority {ruleDto.Priority.Value} already exists for this client" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"A seeding rule with priority {ruleDto.Priority.Value} already exists for this client");
}
int priority = ruleDto.Priority ?? (existingRules.Count == 0 ? 1 : existingRules.Max(r => r.Priority) + 1);
@@ -98,17 +88,6 @@ public class SeedingRulesController : ControllerBase
return CreatedAtAction(nameof(GetSeedingRules), new { downloadClientId }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for seeding rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create seeding rule: {RuleName} for client {ClientId}",
ruleDto.Name, downloadClientId);
return StatusCode(500, new { Message = "Failed to create seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -118,11 +97,6 @@ public class SeedingRulesController : ControllerBase
[HttpPut("{id}")]
public async Task<IActionResult> UpdateSeedingRule(Guid id, [FromBody] SeedingRuleRequest ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -130,7 +104,7 @@ public class SeedingRulesController : ControllerBase
if (existingRule is null)
{
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeding rule with ID {id} not found");
}
existingRule.Name = ruleDto.Name.Trim();
@@ -162,16 +136,6 @@ public class SeedingRulesController : ControllerBase
return Ok(existingRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for seeding rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update seeding rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -181,11 +145,6 @@ public class SeedingRulesController : ControllerBase
[HttpPut("{downloadClientId}/reorder")]
public async Task<IActionResult> ReorderSeedingRules(Guid downloadClientId, [FromBody] ReorderSeedingRulesRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -195,24 +154,24 @@ public class SeedingRulesController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
List<ISeedingRule> rules = await SeedingRuleHelper.GetForClientTrackedAsync(_dataContext, client);
if (request.OrderedIds.Distinct().Count() != request.OrderedIds.Count)
{
return BadRequest(new { Message = "Duplicate rule IDs are not allowed" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Duplicate rule IDs are not allowed");
}
if (request.OrderedIds.Count != rules.Count)
{
return BadRequest(new { Message = $"Expected {rules.Count} rule IDs but received {request.OrderedIds.Count}. All rules must be included." });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Expected {rules.Count} rule IDs but received {request.OrderedIds.Count}. All rules must be included.");
}
foreach (Guid id in request.OrderedIds.Where(id => rules.All(r => r.Id != id)))
{
return BadRequest(new { Message = $"Rule with ID {id} not found for client {downloadClientId}" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Rule with ID {id} not found for client {downloadClientId}");
}
int priority = 1;
@@ -229,11 +188,6 @@ public class SeedingRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reorder seeding rules for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to reorder seeding rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -250,7 +204,7 @@ public class SeedingRulesController : ControllerBase
if (existingRule is null)
{
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeding rule with ID {id} not found");
}
RemoveRuleFromDbSet(existingRule);
@@ -260,11 +214,6 @@ public class SeedingRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete seeding rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Persistence;
@@ -37,7 +38,7 @@ public class UnlinkedConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.UnlinkedConfigs
@@ -55,11 +56,6 @@ public class UnlinkedConfigController : ControllerBase
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateUnlinkedConfig(Guid downloadClientId, [FromBody] UnlinkedConfigRequest dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -69,7 +65,7 @@ public class UnlinkedConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existing = await _dataContext.UnlinkedConfigs

View File

@@ -1,6 +1,7 @@
using System;
using System.Linq;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
@@ -73,11 +74,6 @@ public sealed class DownloadClientController : ControllerBase
return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create download client");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -97,7 +93,7 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {id} not found");
}
var clientToPersist = updatedClient.ApplyTo(existingClient);
@@ -108,11 +104,6 @@ public sealed class DownloadClientController : ControllerBase
return Ok(clientToPersist);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -130,7 +121,7 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {id} not found");
}
_dataContext.DownloadClients.Remove(existingClient);
@@ -143,11 +134,6 @@ public sealed class DownloadClientController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -171,7 +157,7 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {request.ClientId.Value} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {request.ClientId.Value} not found");
}
resolvedPassword = existingClient.Password;
@@ -190,12 +176,12 @@ public sealed class DownloadClientController : ControllerBase
});
}
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
return this.ProblemResult(StatusCodes.Status400BadRequest, healthResult.ErrorMessage ?? "Connection failed");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}");
}
}
}

View File

@@ -99,11 +99,6 @@ public sealed class GeneralConfigController : ControllerBase
return Ok(new { Message = "General configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save General configuration");
throw;
}
finally
{
DataContext.Lock.Release();

View File

@@ -74,15 +74,6 @@ public sealed class MalwareBlockerConfigController : ControllerBase
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
throw;
}
finally
{
DataContext.Lock.Release();

View File

@@ -1,14 +1,11 @@
using System.Net;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
@@ -123,18 +120,18 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.ApiKey.IsPlaceholder())
{
return BadRequest("API key cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "API key cannot be a placeholder value");
}
var notifiarrConfig = new NotifiarrConfig
@@ -168,11 +165,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Notifiarr provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -187,23 +179,23 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.Key.IsPlaceholder())
{
return BadRequest("Key cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Key cannot be a placeholder value");
}
if (newProvider.ServiceUrls.IsPlaceholder())
{
return BadRequest("Service URLs cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Service URLs cannot be a placeholder value");
}
var appriseConfig = new AppriseConfig
@@ -240,15 +232,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Apprise provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -263,23 +246,23 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.Password.IsPlaceholder())
{
return BadRequest("Password cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Password cannot be a placeholder value");
}
if (newProvider.AccessToken.IsPlaceholder())
{
return BadRequest("Access token cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Access token cannot be a placeholder value");
}
var ntfyConfig = new NtfyConfig
@@ -319,15 +302,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Ntfy provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -342,18 +316,18 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.BotToken.IsPlaceholder())
{
return BadRequest("Bot token cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Bot token cannot be a placeholder value");
}
var telegramConfig = new TelegramConfig
@@ -389,15 +363,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Telegram provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -416,12 +381,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Notifiarr provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Notifiarr provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -430,7 +395,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var notifiarrConfig = new NotifiarrConfig
@@ -472,15 +437,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Notifiarr provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -499,12 +455,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Apprise provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Apprise provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -513,7 +469,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var appriseConfig = new AppriseConfig
@@ -560,15 +516,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Apprise provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -587,12 +534,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Ntfy provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Ntfy provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -601,7 +548,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var ntfyConfig = new NtfyConfig
@@ -651,15 +598,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Ntfy provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -678,12 +616,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Telegram provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Telegram provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -692,7 +630,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var telegramConfig = new TelegramConfig
@@ -736,15 +674,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Telegram provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -769,7 +698,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Notification provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Notification provider with ID {id} not found");
}
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -782,11 +711,6 @@ public sealed class NotificationProvidersController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete notification provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -807,7 +731,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "API key cannot be a placeholder value" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "API key cannot be a placeholder value");
}
apiKey = existing.ApiKey;
@@ -845,8 +769,7 @@ public sealed class NotificationProvidersController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -865,7 +788,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Sensitive fields cannot be placeholder values");
}
if (key.IsPlaceholder())
@@ -912,14 +835,9 @@ public sealed class NotificationProvidersController : ControllerBase
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (AppriseException exception)
{
return StatusCode((int)HttpStatusCode.InternalServerError, exception.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -938,7 +856,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Sensitive fields cannot be placeholder values");
}
if (password.IsPlaceholder())
@@ -990,8 +908,7 @@ public sealed class NotificationProvidersController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Ntfy provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1009,7 +926,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Bot token cannot be a placeholder value" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Bot token cannot be a placeholder value");
}
botToken = existing.BotToken;
@@ -1047,15 +964,9 @@ public sealed class NotificationProvidersController : ControllerBase
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (TelegramException ex)
{
_logger.LogWarning(ex, "Failed to test Telegram provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Telegram provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1100,18 +1011,18 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.WebhookUrl.IsPlaceholder())
{
return BadRequest("Webhook URL cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Webhook URL cannot be a placeholder value");
}
var discordConfig = new DiscordConfig
@@ -1146,15 +1057,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Discord provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1173,12 +1075,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Discord provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Discord provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -1187,7 +1089,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var discordConfig = new DiscordConfig
@@ -1230,15 +1132,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Discord provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1259,7 +1152,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Webhook URL cannot be a placeholder value" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Webhook URL cannot be a placeholder value");
}
webhookUrl = existing.WebhookUrl;
@@ -1296,15 +1189,9 @@ public sealed class NotificationProvidersController : ControllerBase
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (DiscordException ex)
{
_logger.LogWarning(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1316,23 +1203,23 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.ApiToken.IsPlaceholder())
{
return BadRequest("API token cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "API token cannot be a placeholder value");
}
if (newProvider.UserKey.IsPlaceholder())
{
return BadRequest("User key cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "User key cannot be a placeholder value");
}
var pushoverConfig = new PushoverConfig
@@ -1372,15 +1259,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Pushover provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1399,12 +1277,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Pushover provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Pushover provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -1413,7 +1291,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var pushoverConfig = new PushoverConfig
@@ -1463,15 +1341,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Pushover provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1493,7 +1362,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Sensitive fields cannot be placeholder values");
}
if (apiToken.IsPlaceholder())
@@ -1545,8 +1414,7 @@ public sealed class NotificationProvidersController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Pushover provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1558,18 +1426,18 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.ApplicationToken.IsPlaceholder())
{
return BadRequest("Application token cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Application token cannot be a placeholder value");
}
var gotifyConfig = new GotifyConfig
@@ -1604,15 +1472,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Gotify provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1631,12 +1490,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Gotify provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Gotify provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -1645,7 +1504,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var gotifyConfig = new GotifyConfig
@@ -1688,15 +1547,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Gotify provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1716,7 +1566,9 @@ public sealed class NotificationProvidersController : ControllerBase
testRequest.ProviderId, NotificationProviderType.Gotify, p => p.GotifyConfiguration);
if (existing is null)
return BadRequest(new { Message = "Application token cannot be a placeholder value" });
{
return this.ProblemResult(StatusCodes.Status400BadRequest, "Application token cannot be a placeholder value");
}
applicationToken = existing.ApplicationToken;
}
@@ -1752,15 +1604,9 @@ public sealed class NotificationProvidersController : ControllerBase
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (GotifyException ex)
{
_logger.LogWarning(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}

View File

@@ -1,5 +1,3 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
@@ -80,15 +78,6 @@ public sealed class QueueCleanerConfigController : ControllerBase
return Ok(new { Message = "QueueCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save QueueCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
@@ -43,11 +43,6 @@ public class QueueRulesController : ControllerBase
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve stall rules");
return StatusCode(500, new { Message = "Failed to retrieve stall rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -57,11 +52,6 @@ public class QueueRulesController : ControllerBase
[HttpPost("stall")]
public async Task<IActionResult> CreateStallRule([FromBody] StallRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -73,7 +63,7 @@ public class QueueRulesController : ControllerBase
if (existingRule != null)
{
return BadRequest(new { Message = "A stall rule with this name already exists" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "A stall rule with this name already exists");
}
var rule = new StallRule
@@ -97,7 +87,7 @@ public class QueueRulesController : ControllerBase
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(rule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
return this.ProblemResult(StatusCodes.Status400BadRequest, intervalValidationResult.ErrorMessage);
}
rule.Validate();
@@ -109,16 +99,6 @@ public class QueueRulesController : ControllerBase
return CreatedAtAction(nameof(GetStallRules), new { id = rule.Id }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for stall rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create stall rule: {RuleName}", ruleDto.Name);
return StatusCode(500, new { Message = "Failed to create stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -128,11 +108,6 @@ public class QueueRulesController : ControllerBase
[HttpPut("stall/{id}")]
public async Task<IActionResult> UpdateStallRule(Guid id, [FromBody] StallRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -141,15 +116,15 @@ public class QueueRulesController : ControllerBase
if (existingRule == null)
{
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Stall rule with ID {id} not found");
}
var duplicateRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
if (duplicateRule != null)
{
return BadRequest(new { Message = "A stall rule with this name already exists" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "A stall rule with this name already exists");
}
var updatedRule = existingRule with
@@ -173,7 +148,7 @@ public class QueueRulesController : ControllerBase
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(updatedRule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
return this.ProblemResult(StatusCodes.Status400BadRequest, intervalValidationResult.ErrorMessage);
}
updatedRule.Validate();
@@ -185,16 +160,6 @@ public class QueueRulesController : ControllerBase
return Ok(updatedRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for stall rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update stall rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -212,7 +177,7 @@ public class QueueRulesController : ControllerBase
if (existingRule == null)
{
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Stall rule with ID {id} not found");
}
_dataContext.StallRules.Remove(existingRule);
@@ -222,11 +187,6 @@ public class QueueRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete stall rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -247,11 +207,6 @@ public class QueueRulesController : ControllerBase
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve slow rules");
return StatusCode(500, new { Message = "Failed to retrieve slow rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -261,11 +216,6 @@ public class QueueRulesController : ControllerBase
[HttpPost("slow")]
public async Task<IActionResult> CreateSlowRule([FromBody] SlowRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -277,7 +227,7 @@ public class QueueRulesController : ControllerBase
if (existingRule != null)
{
return BadRequest(new { Message = "A slow rule with this name already exists" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "A slow rule with this name already exists");
}
var rule = new SlowRule
@@ -303,7 +253,7 @@ public class QueueRulesController : ControllerBase
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(rule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
return this.ProblemResult(StatusCodes.Status400BadRequest, intervalValidationResult.ErrorMessage);
}
rule.Validate();
@@ -315,16 +265,6 @@ public class QueueRulesController : ControllerBase
return CreatedAtAction(nameof(GetSlowRules), new { id = rule.Id }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for slow rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create slow rule: {RuleName}", ruleDto.Name);
return StatusCode(500, new { Message = "Failed to create slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -334,11 +274,6 @@ public class QueueRulesController : ControllerBase
[HttpPut("slow/{id}")]
public async Task<IActionResult> UpdateSlowRule(Guid id, [FromBody] SlowRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -347,15 +282,15 @@ public class QueueRulesController : ControllerBase
if (existingRule == null)
{
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Slow rule with ID {id} not found");
}
var duplicateRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
if (duplicateRule != null)
{
return BadRequest(new { Message = "A slow rule with this name already exists" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "A slow rule with this name already exists");
}
var updatedRule = existingRule with
@@ -381,7 +316,7 @@ public class QueueRulesController : ControllerBase
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(updatedRule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
return this.ProblemResult(StatusCodes.Status400BadRequest, intervalValidationResult.ErrorMessage);
}
updatedRule.Validate();
@@ -393,16 +328,6 @@ public class QueueRulesController : ControllerBase
return Ok(updatedRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for slow rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update slow rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -420,7 +345,7 @@ public class QueueRulesController : ControllerBase
if (existingRule == null)
{
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Slow rule with ID {id} not found");
}
_dataContext.SlowRules.Remove(existingRule);
@@ -430,11 +355,6 @@ public class QueueRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete slow rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
using Cleanuparr.Shared.Helpers;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
@@ -89,7 +90,7 @@ public sealed class SeekerConfigController : ControllerBase
{
if (!await DataContext.Lock.WaitAsync(TimeSpan.FromSeconds(30)))
{
return StatusCode(503, "Database is busy, please try again");
return this.ProblemResult(StatusCodes.Status503ServiceUnavailable, "Database is busy, please try again");
}
try
@@ -194,11 +195,6 @@ public sealed class SeekerConfigController : ControllerBase
return Ok(new { Message = "Seeker configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Seeker configuration");
throw;
}
finally
{
DataContext.Lock.Release();

View File

@@ -1,77 +0,0 @@
using System.Net;
using System.Text.Json;
using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Exceptions;
namespace Cleanuparr.Api.Middleware;
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
// Generate a unique identifier for this error
string traceId = Guid.NewGuid().ToString();
// Default status code and message
int statusCode = (int)HttpStatusCode.InternalServerError;
string message = "An unexpected error occurred";
switch (exception)
{
// Handle different exception types
case ValidationException:
statusCode = (int)HttpStatusCode.BadRequest;
message = exception.Message; // Use the validation message directly
_logger.LogWarning(exception,
"Validation error {TraceId} occurred during request to {Path}",
traceId, context.Request.Path);
break;
default:
// Log other exceptions as errors with more details
_logger.LogError(exception,
"Error {TraceId} occurred during request to {Path}: {Message}",
traceId, context.Request.Path, exception.Message);
break;
}
// Create the error response
ErrorResponse errorResponse = new()
{
TraceId = traceId,
Error = message
};
// Set the response
context.Response.ContentType = "application/json";
context.Response.StatusCode = statusCode;
// Write the response
await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
}

View File

@@ -0,0 +1,76 @@
using Cleanuparr.Domain.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
namespace Cleanuparr.Api.Middleware;
/// <summary>
/// Single source of truth for mapping unhandled exceptions to RFC 9457 problem-details responses.
/// Registered via <c>AddExceptionHandler</c> + <c>UseExceptionHandler</c>.
/// </summary>
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
private readonly ProblemDetailsFactory _problemDetailsFactory;
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ProblemDetailsFactory problemDetailsFactory,
ILogger<GlobalExceptionHandler> logger)
{
_problemDetailsService = problemDetailsService;
_problemDetailsFactory = problemDetailsFactory;
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
{
(int status, string title, string detail) = exception switch
{
ValidationException => (StatusCodes.Status400BadRequest, "Validation failed", exception.Message),
NotificationTestException => (StatusCodes.Status400BadRequest, "Notification test failed", exception.Message),
RateLimitException => (StatusCodes.Status429TooManyRequests, "Too many requests", exception.Message),
_ => (StatusCodes.Status500InternalServerError, "An error occurred", "An unexpected error occurred"),
};
string path = Sanitize(context.Request.Path);
if (status >= StatusCodes.Status500InternalServerError)
{
_logger.LogError(exception, "Unhandled error during request to {Path}", path);
}
else
{
_logger.LogWarning(exception, "Handled {Status} during request to {Path}: {Message}",
status, path, Sanitize(exception.Message));
}
context.Response.StatusCode = status;
ProblemDetails problemDetails = _problemDetailsFactory.CreateProblemDetails(
context, statusCode: status, title: title, detail: detail);
if (exception is RateLimitException { RetryAfterSeconds: > 0 } rateLimitException)
{
problemDetails.Extensions["retryAfterSeconds"] = rateLimitException.RetryAfterSeconds;
context.Response.Headers.RetryAfter = rateLimitException.RetryAfterSeconds.ToString();
}
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails = problemDetails,
Exception = exception,
});
}
/// <summary>
/// Strips line breaks from user-controlled values before they reach the logs to prevent log forging.
/// </summary>
private static string Sanitize(string? value)
{
return value is null ? string.Empty : value.Replace("\r", string.Empty).Replace("\n", string.Empty);
}
}

View File

@@ -1,17 +0,0 @@
namespace Cleanuparr.Api.Models;
/// <summary>
/// Standardized error response model for API endpoints
/// </summary>
public class ErrorResponse
{
/// <summary>
/// User-friendly error message
/// </summary>
public required string Error { get; set; }
/// <summary>
/// Trace ID for error tracking (GUID)
/// </summary>
public required string TraceId { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Thrown when a notification provider connectivity test fails. Maps to HTTP 400 so a failed
/// test is reported as a bad request rather than an unexpected server error.
/// </summary>
public sealed class NotificationTestException : Exception
{
public NotificationTestException(string message) : base(message)
{
}
public NotificationTestException(string message, Exception inner) : base(message, inner)
{
}
}

View File

@@ -0,0 +1,20 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Thrown when a request is rejected due to rate limiting. Maps to HTTP 429 with a
/// <c>retryAfterSeconds</c> problem-details extension and a <c>Retry-After</c> header.
/// </summary>
public sealed class RateLimitException : Exception
{
public int RetryAfterSeconds { get; }
public RateLimitException(string message, int retryAfterSeconds = 0) : base(message)
{
RetryAfterSeconds = retryAfterSeconds;
}
public RateLimitException(string message, Exception inner, int retryAfterSeconds = 0) : base(message, inner)
{
RetryAfterSeconds = retryAfterSeconds;
}
}

View File

@@ -1,33 +1,55 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
traceId?: string;
retryAfterSeconds?: number;
}
export class ApiError extends Error {
retryAfterSeconds?: number;
statusCode?: number;
traceId?: string;
}
function resolveMessage(error: HttpErrorResponse): string {
// Transport-level failure: no HTTP response reached the client (offline, CORS, DNS, ...).
if (error.status === 0 || error.error instanceof ProgressEvent) {
return 'Unable to reach the server';
}
// Client-side error raised while processing the request.
if (error.error instanceof ErrorEvent) {
return error.error.message;
}
// Plain-string body.
if (typeof error.error === 'string' && error.error.length > 0) {
return error.error;
}
// Structured body: prefer RFC 9457 ProblemDetails, then fall back to legacy { error } / { message } shapes.
const body = error.error as (ProblemDetails & { error?: string; message?: string }) | null;
return body?.detail ?? body?.title ?? body?.error ?? body?.message ?? `Error ${error.status}`;
}
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
let message = 'An unexpected error occurred';
const apiError = new ApiError(resolveMessage(error));
apiError.statusCode = error.status;
if (error.error instanceof ErrorEvent) {
// Client-side error
message = error.error.message;
} else if (typeof error.error === 'string') {
// Server-side error with plain string body
message = error.error;
} else {
// Server-side error with JSON body
message = error.error?.error
?? error.error?.message
?? error.message
?? `Error ${error.status}`;
const body = error.error;
if (body && typeof body === 'object' && !(body instanceof ErrorEvent) && !(body instanceof ProgressEvent)) {
const problem = body as ProblemDetails;
apiError.retryAfterSeconds = problem.retryAfterSeconds;
apiError.traceId = problem.traceId;
}
const apiError = new ApiError(message);
apiError.retryAfterSeconds = error.error?.retryAfterSeconds;
apiError.statusCode = error.status;
return throwError(() => apiError);
}),
);