From 4f7e2d33b48c8e618d6251b0dc34880371cae736 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 16 Jun 2026 19:16:03 +0300 Subject: [PATCH] added webhook-triggered targeted MalwareBlocker scan --- .../MalwareBlockerConfigController.cs | 6 +- .../Webhooks/Contracts/ArrWebhookPayload.cs | 25 ++++ .../MalwareBlockerWebhookController.cs | 107 ++++++++++++++++++ .../Jobs/BackgroundJobManager.cs | 27 +++-- .../backend/Cleanuparr.Api/Jobs/GenericJob.cs | 27 +++++ .../Features/Jobs/MalwareBlocker.cs | 49 +++++++- .../Features/Jobs/WebhookScanTarget.cs | 16 +++ .../Interfaces/IJobManagementService.cs | 7 ++ .../Services/JobManagementService.cs | 49 ++++++++ .../Cleanuparr.Shared/Helpers/Constants.cs | 18 +++ 10 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 code/backend/Cleanuparr.Api/Features/Webhooks/Contracts/ArrWebhookPayload.cs create mode 100644 code/backend/Cleanuparr.Api/Features/Webhooks/Controllers/MalwareBlockerWebhookController.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Jobs/WebhookScanTarget.cs diff --git a/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Controllers/MalwareBlockerConfigController.cs b/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Controllers/MalwareBlockerConfigController.cs index b4508e79..49ca32fc 100644 --- a/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Controllers/MalwareBlockerConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Controllers/MalwareBlockerConfigController.cs @@ -82,7 +82,11 @@ public sealed class MalwareBlockerConfigController : ControllerBase private async Task UpdateJobSchedule(IJobConfig config, JobType jobType) { - if (config.Enabled) + // Webhook-only mode keeps the feature enabled but removes the cron trigger. + bool scheduleEnabled = config.Enabled && + config is not ContentBlockerConfig { TriggerMode: MalwareBlockerTriggerMode.Webhook }; + + if (scheduleEnabled) { if (!string.IsNullOrEmpty(config.CronExpression)) { diff --git a/code/backend/Cleanuparr.Api/Features/Webhooks/Contracts/ArrWebhookPayload.cs b/code/backend/Cleanuparr.Api/Features/Webhooks/Contracts/ArrWebhookPayload.cs new file mode 100644 index 00000000..2a8a8b28 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Webhooks/Contracts/ArrWebhookPayload.cs @@ -0,0 +1,25 @@ +namespace Cleanuparr.Api.Features.Webhooks.Contracts; + +/// +/// Minimal, tolerant projection of the Sonarr/Radarr "On Grab" Webhook payload. Only the fields used +/// to trigger a targeted MalwareBlocker scan are bound; all other fields are ignored. +/// +public sealed record ArrWebhookPayload +{ + /// "Grab" to act on; "Test" is sent when the connection's Test button is clicked. + public string? EventType { get; init; } + + /// Torrent infohash (or NZB id) identifying the download in the download client. + public string? DownloadId { get; init; } + + /// Present on Sonarr payloads; carries the series content id. + public ArrWebhookContent? Series { get; init; } + + /// Present on Radarr payloads; carries the movie content id. + public ArrWebhookContent? Movie { get; init; } +} + +public sealed record ArrWebhookContent +{ + public long Id { get; init; } +} diff --git a/code/backend/Cleanuparr.Api/Features/Webhooks/Controllers/MalwareBlockerWebhookController.cs b/code/backend/Cleanuparr.Api/Features/Webhooks/Controllers/MalwareBlockerWebhookController.cs new file mode 100644 index 00000000..81e55c19 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Webhooks/Controllers/MalwareBlockerWebhookController.cs @@ -0,0 +1,107 @@ +using Cleanuparr.Api.Features.Webhooks.Contracts; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Api.Features.Webhooks.Controllers; + +/// +/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the +/// grabbed download. Authentication reuses the account API key (e.g. ?apikey=), so the URL can +/// be pasted directly into the *arr Webhook connection. +/// +[ApiController] +[Route("api/webhooks")] +[Authorize] +public sealed class MalwareBlockerWebhookController : ControllerBase +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly IJobManagementService _jobManagementService; + + public MalwareBlockerWebhookController( + ILogger logger, + DataContext dataContext, + IJobManagementService jobManagementService) + { + _logger = logger; + _dataContext = dataContext; + _jobManagementService = jobManagementService; + } + + [HttpPost("malware-blocker/{instanceId:guid}")] + public async Task TriggerMalwareBlocker(Guid instanceId, [FromBody] ArrWebhookPayload payload) + { + // The Test button sends an event we acknowledge without doing any work. + if (string.Equals(payload.EventType, "Test", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Received MalwareBlocker test webhook for instance {instanceId}", instanceId); + return Ok(); + } + + if (!string.Equals(payload.EventType, "Grab", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Ignoring MalwareBlocker webhook event '{eventType}' for instance {instanceId}", + payload.EventType, instanceId); + return Ok(); + } + + ArrConfig? arrConfig; + ArrInstance? instance; + ContentBlockerConfig config; + + await DataContext.Lock.WaitAsync(); + try + { + arrConfig = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId)); + instance = arrConfig?.Instances.FirstOrDefault(i => i.Id == instanceId); + config = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync(); + } + finally + { + DataContext.Lock.Release(); + } + + if (arrConfig is null || instance is null) + { + return NotFound($"No arr instance found with id {instanceId}"); + } + + if (arrConfig.Type is not (InstanceType.Sonarr or InstanceType.Radarr)) + { + return UnprocessableEntity("MalwareBlocker webhooks are only supported for Sonarr and Radarr"); + } + + if (!config.Enabled || config.TriggerMode is MalwareBlockerTriggerMode.Schedule) + { + _logger.LogDebug("Ignoring MalwareBlocker webhook | webhook triggering is not enabled"); + return Ok(); + } + + if (string.IsNullOrWhiteSpace(payload.DownloadId)) + { + _logger.LogDebug("Ignoring MalwareBlocker webhook | no download id in payload (usenet or pre-grab)"); + return Ok(); + } + + long contentId = arrConfig.Type switch + { + InstanceType.Sonarr => payload.Series?.Id ?? 0, + InstanceType.Radarr => payload.Movie?.Id ?? 0, + _ => 0, + }; + + await _jobManagementService.TriggerMalwareBlockerWebhook(instanceId, payload.DownloadId, contentId, arrConfig.Type); + + return Ok(); + } +} diff --git a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs index e7f8fcd1..3f834d2d 100644 --- a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs +++ b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs @@ -1,3 +1,4 @@ +using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Infrastructure.Features.BlacklistSync; using Cleanuparr.Infrastructure.Features.Jobs; @@ -112,6 +113,7 @@ public class BackgroundJobManager : IHostedService // Always register jobs, regardless of enabled status await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken); await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken); + await RegisterMalwareBlockerWebhookJob(cancellationToken); await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken); await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken); await RegisterSeekerJob(seekerConfig, cancellationToken); @@ -144,13 +146,24 @@ public class BackgroundJobManager : IHostedService { // Always register the job definition await AddJobWithoutTrigger(cancellationToken); - - // Only add triggers if the job is enabled - if (config.Enabled) + + // Only add the cron trigger when scheduling is part of the trigger mode + if (config.Enabled && config.TriggerMode is not MalwareBlockerTriggerMode.Webhook) { await AddTriggersForJob(config.CronExpression, cancellationToken); } } + + /// + /// Registers the webhook-triggered MalwareBlocker job under a dedicated JobKey (no cron trigger). + /// The dedicated key gives webhook runs their own DisallowConcurrentExecution lock, independent of + /// the scheduled MalwareBlocker job. Triggers are scheduled on demand when an "On Grab" webhook + /// is received. + /// + public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default) + { + await AddJobWithoutTrigger(cancellationToken, Constants.MalwareBlockerWebhookJobKey); + } /// /// Registers the DownloadCleaner job and optionally adds triggers based on configuration. @@ -273,17 +286,17 @@ public class BackgroundJobManager : IHostedService /// /// Helper method to add a job without a trigger (for chained jobs). /// - private async Task AddJobWithoutTrigger(CancellationToken cancellationToken = default) + private async Task AddJobWithoutTrigger(CancellationToken cancellationToken = default, string? jobKeyName = null) where T : IHandler { if (_scheduler == null) { throw new InvalidOperationException("Scheduler not initialized"); } - - string typeName = typeof(T).Name; + + string typeName = jobKeyName ?? typeof(T).Name; var jobKey = new JobKey(typeName); - + // Check if job already exists if (await _scheduler.CheckExists(jobKey, cancellationToken)) { diff --git a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs index f09a78ad..ba43d464 100644 --- a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs +++ b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs @@ -48,6 +48,8 @@ public sealed class GenericJob : IJob ContextProvider.SetJobRunId(jobRunId); using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString()); + SetWebhookScanTarget(context); + await BroadcastJobStatus(hubContext, jobManagementService, jobType, false); var handler = scope.ServiceProvider.GetRequiredService(); @@ -75,6 +77,31 @@ public sealed class GenericJob : IJob } } + /// + /// When the firing trigger carries a webhook scan target in its JobDataMap, surfaces it to the + /// handler via the ContextProvider so the run scans only that download. No-op for normal triggers. + /// + private static void SetWebhookScanTarget(IJobExecutionContext context) + { + JobDataMap dataMap = context.MergedJobDataMap; + + if (!dataMap.ContainsKey(WebhookScanTarget.InstanceIdKey)) + { + return; + } + + if (!Guid.TryParse(dataMap.GetString(WebhookScanTarget.InstanceIdKey), out Guid instanceId) || + !Enum.TryParse(dataMap.GetString(WebhookScanTarget.InstanceTypeKey), out InstanceType instanceType)) + { + return; + } + + string downloadId = dataMap.GetString(WebhookScanTarget.DownloadIdKey) ?? string.Empty; + long contentId = dataMap.GetLong(WebhookScanTarget.ContentIdKey); + + ContextProvider.Set(new WebhookScanTarget(instanceId, downloadId, contentId, instanceType)); + } + private async Task BroadcastJobStatus(IHubContext hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished) { try diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs index 995ae9cd..c8a6771f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs @@ -62,6 +62,12 @@ public sealed class MalwareBlocker : GenericHandler await _blocklistProvider.LoadBlocklistsAsync(); + if (ContextProvider.Get(nameof(WebhookScanTarget)) is WebhookScanTarget webhookTarget) + { + await ProcessWebhookTargetAsync(malwareBlockerConfig, webhookTarget); + return; + } + var sonarrConfig = ContextProvider.Get(nameof(InstanceType.Sonarr)); var radarrConfig = ContextProvider.Get(nameof(InstanceType.Radarr)); var lidarrConfig = ContextProvider.Get(nameof(InstanceType.Lidarr)); @@ -94,8 +100,43 @@ public sealed class MalwareBlocker : GenericHandler } } + /// + /// Scans a single download identified by an *arr "On Grab" webhook, restricted to the originating + /// instance, instead of iterating the whole queue. Sonarr/Radarr only. + /// + private async Task ProcessWebhookTargetAsync(ContentBlockerConfig config, WebhookScanTarget target) + { + BlocklistSettings? blocklist = target.Type switch + { + InstanceType.Sonarr => config.Sonarr, + InstanceType.Radarr => config.Radarr, + _ => null, + }; + + if (blocklist is null || !blocklist.Enabled) + { + _logger.LogDebug("skip webhook scan | blocklist for {type} is not enabled", target.Type); + return; + } + + ArrConfig arrConfig = ContextProvider.Get(target.Type.ToString()); + ArrInstance? instance = arrConfig.Instances + .FirstOrDefault(x => x.Id == target.InstanceId && x.Enabled); + + if (instance is null) + { + _logger.LogWarning("skip webhook scan | instance {id} not found or disabled", target.InstanceId); + return; + } + + instance.ArrConfig = arrConfig; + await ProcessInstanceAsync(instance); + } + protected override async Task ProcessInstanceAsync(ArrInstance instance) { + WebhookScanTarget? webhookTarget = ContextProvider.Get(nameof(WebhookScanTarget)) as WebhookScanTarget; + List ignoredDownloads = ContextProvider.Get(nameof(GeneralConfig)).IgnoredDownloads; ignoredDownloads.AddRange(ContextProvider.Get().IgnoredDownloads); @@ -129,6 +170,12 @@ public sealed class MalwareBlocker : GenericHandler continue; } + if (webhookTarget is not null && + !string.Equals(record.DownloadId, webhookTarget.DownloadId, StringComparison.InvariantCultureIgnoreCase)) + { + continue; + } + if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase)) { _logger.LogInformation("skip | {title} | ignored", record.Title); @@ -231,6 +278,6 @@ public sealed class MalwareBlocker : GenericHandler downloadClient: foundInClient ); } - }); + }, contentId: webhookTarget is { ContentId: > 0 } ? webhookTarget.ContentId : null); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/WebhookScanTarget.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/WebhookScanTarget.cs new file mode 100644 index 00000000..44261737 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/WebhookScanTarget.cs @@ -0,0 +1,16 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Infrastructure.Features.Jobs; + +/// +/// Identifies a single download that should be scanned by the MalwareBlocker as the result of an +/// *arr "On Grab" webhook, instead of iterating the whole queue. Carried through the Quartz +/// JobDataMap and surfaced to the handler via . +/// +public sealed record WebhookScanTarget(Guid InstanceId, string DownloadId, long ContentId, InstanceType Type) +{ + public const string InstanceIdKey = "webhook.instanceId"; + public const string DownloadIdKey = "webhook.downloadId"; + public const string ContentIdKey = "webhook.contentId"; + public const string InstanceTypeKey = "webhook.instanceType"; +} diff --git a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IJobManagementService.cs b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IJobManagementService.cs index d3828312..006ed1c6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IJobManagementService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IJobManagementService.cs @@ -9,6 +9,13 @@ public interface IJobManagementService Task StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null); Task StopJob(JobType jobType); Task TriggerJobOnce(JobType jobType); + + /// + /// Schedules targeted MalwareBlocker scans for a single download received via an *arr "On Grab" + /// webhook. Runs immediately plus a few delayed retries (to catch late torrent metadata) on the + /// dedicated webhook JobKey, independent of the scheduled MalwareBlocker job. + /// + Task TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type); Task> GetAllJobs(IScheduler? scheduler = null); Task GetJob(JobType jobType); Task UpdateJobSchedule(JobType jobType, JobSchedule schedule); diff --git a/code/backend/Cleanuparr.Infrastructure/Services/JobManagementService.cs b/code/backend/Cleanuparr.Infrastructure/Services/JobManagementService.cs index a7caa334..7ba3c544 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/JobManagementService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/JobManagementService.cs @@ -1,8 +1,10 @@ using System.Collections.Concurrent; using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Jobs; using Cleanuparr.Infrastructure.Models; using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Infrastructure.Utilities; +using Cleanuparr.Shared.Helpers; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using Quartz; @@ -368,6 +370,53 @@ public class JobManagementService : IJobManagementService } } + public async Task TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type) + { + try + { + var scheduler = await _schedulerFactory.GetScheduler(); + var jobKey = new JobKey(Constants.MalwareBlockerWebhookJobKey); + + if (!await scheduler.CheckExists(jobKey)) + { + _logger.LogError("Job {name} does not exist", Constants.MalwareBlockerWebhookJobKey); + return false; + } + + long ticks = DateTimeOffset.UtcNow.Ticks; + + foreach (TimeSpan delay in Constants.MalwareBlockerWebhookRetryDelays) + { + var jobData = new JobDataMap + { + { WebhookScanTarget.InstanceIdKey, instanceId.ToString() }, + { WebhookScanTarget.DownloadIdKey, downloadId }, + { WebhookScanTarget.ContentIdKey, contentId }, + { WebhookScanTarget.InstanceTypeKey, type.ToString() }, + }; + + var trigger = TriggerBuilder.Create() + .WithIdentity($"{Constants.MalwareBlockerWebhookJobKey}-{instanceId}-{downloadId}-{(int)delay.TotalSeconds}-{ticks}") + .ForJob(jobKey) + .UsingJobData(jobData) + .StartAt(DateTimeOffset.UtcNow.Add(delay)) + .Build(); + + await scheduler.ScheduleJob(trigger); + } + + _logger.LogInformation( + "MalwareBlocker webhook scan scheduled for download {downloadId} on {type} instance {instanceId}", + downloadId, type, instanceId); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error scheduling MalwareBlocker webhook scan for instance {instanceId}", instanceId); + return false; + } + } + public async Task UpdateJobSchedule(JobType jobType, JobSchedule schedule) { if (schedule == null) diff --git a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs index bc5cf41c..89acd86a 100644 --- a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs +++ b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs @@ -22,4 +22,22 @@ public static class Constants public const string LogoUrl = "https://cdn.jsdelivr.net/gh/Cleanuparr/Cleanuparr@main/Logo/48.png"; public const string CustomFormatScoreSyncerCron = "0 0/30 * * * ?"; + + /// + /// Quartz JobKey for webhook-triggered MalwareBlocker runs. Distinct from the cron JobKey + /// ("MalwareBlocker") so the two have independent DisallowConcurrentExecution locks. + /// + public const string MalwareBlockerWebhookJobKey = "MalwareBlockerWebhook"; + + /// + /// Delays (relative to receiving the "On Grab" webhook) at which the targeted MalwareBlocker scan + /// is run. The first run is immediate; later runs catch torrents whose file metadata was not yet + /// available. Once a download is acted on, retries become safe no-ops. + /// + public static readonly IReadOnlyList MalwareBlockerWebhookRetryDelays = + [ + TimeSpan.Zero, + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(120), + ]; } \ No newline at end of file