mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-06-28 23:35:54 -04:00
added webhook-triggered targeted MalwareBlocker scan
This commit is contained in:
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user