added webhook-triggered targeted MalwareBlocker scan

This commit is contained in:
Flaminel
2026-06-16 19:16:03 +03:00
parent b1b19e5f29
commit 4f7e2d33b4
10 changed files with 322 additions and 9 deletions

View File

@@ -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))
{

View File

@@ -0,0 +1,25 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// 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.
/// </summary>
public sealed record ArrWebhookPayload
{
/// <summary>"Grab" to act on; "Test" is sent when the connection's Test button is clicked.</summary>
public string? EventType { get; init; }
/// <summary>Torrent infohash (or NZB id) identifying the download in the download client.</summary>
public string? DownloadId { get; init; }
/// <summary>Present on Sonarr payloads; carries the series content id.</summary>
public ArrWebhookContent? Series { get; init; }
/// <summary>Present on Radarr payloads; carries the movie content id.</summary>
public ArrWebhookContent? Movie { get; init; }
}
public sealed record ArrWebhookContent
{
public long Id { get; init; }
}

View File

@@ -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;
/// <summary>
/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the
/// grabbed download. Authentication reuses the account API key (e.g. <c>?apikey=</c>), so the URL can
/// be pasted directly into the *arr Webhook connection.
/// </summary>
[ApiController]
[Route("api/webhooks")]
[Authorize]
public sealed class MalwareBlockerWebhookController : ControllerBase
{
private readonly ILogger<MalwareBlockerWebhookController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public MalwareBlockerWebhookController(
ILogger<MalwareBlockerWebhookController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpPost("malware-blocker/{instanceId:guid}")]
public async Task<IActionResult> 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();
}
}

View File

@@ -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<MalwareBlocker>(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<MalwareBlocker>(config.CronExpression, cancellationToken);
}
}
/// <summary>
/// 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.
/// </summary>
public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken, Constants.MalwareBlockerWebhookJobKey);
}
/// <summary>
/// Registers the DownloadCleaner job and optionally adds triggers based on configuration.
@@ -273,17 +286,17 @@ public class BackgroundJobManager : IHostedService
/// <summary>
/// Helper method to add a job without a trigger (for chained jobs).
/// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
private async Task AddJobWithoutTrigger<T>(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))
{

View File

@@ -48,6 +48,8 @@ public sealed class GenericJob<T> : 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<T>();
@@ -75,6 +77,31 @@ public sealed class GenericJob<T> : IJob
}
}
/// <summary>
/// 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.
/// </summary>
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<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try

View File

@@ -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<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
@@ -94,8 +100,43 @@ public sealed class MalwareBlocker : GenericHandler
}
}
/// <summary>
/// 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.
/// </summary>
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<ArrConfig>(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<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().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);
}
}

View File

@@ -0,0 +1,16 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Jobs;
/// <summary>
/// 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 <see cref="Context.ContextProvider"/>.
/// </summary>
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";
}

View File

@@ -9,6 +9,13 @@ public interface IJobManagementService
Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null);
Task<bool> StopJob(JobType jobType);
Task<bool> TriggerJobOnce(JobType jobType);
/// <summary>
/// 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.
/// </summary>
Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type);
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
Task<JobInfo> GetJob(JobType jobType);
Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule);

View File

@@ -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<bool> 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<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule)
{
if (schedule == null)

View File

@@ -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 * * * ?";
/// <summary>
/// Quartz JobKey for webhook-triggered MalwareBlocker runs. Distinct from the cron JobKey
/// ("MalwareBlocker") so the two have independent DisallowConcurrentExecution locks.
/// </summary>
public const string MalwareBlockerWebhookJobKey = "MalwareBlockerWebhook";
/// <summary>
/// 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.
/// </summary>
public static readonly IReadOnlyList<TimeSpan> MalwareBlockerWebhookRetryDelays =
[
TimeSpan.Zero,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(120),
];
}