Findings from a pass over the plugin, smallest first: Redemptions now run one at a time behind a gate. The status checks and the status write that follows them were not atomic, so two requests arriving together with the same one-use token could both mint a guest session. The spent-link check also moved above the tagging step, so hammering an already-used link no longer re-tags a whole series on every hit. The configured maximum expiry is actually respected. Both the API and the picker did Math.max(configured, 720), so setting the ceiling to anything under 30 days was silently ignored. The picker now also hides the quick-pick durations that sit above the ceiling. The share URL, which carries the raw token, is dropped from the record when the link is revoked or expires. Records are never deleted, so dead tokens were accumulating in the store forever. Live links keep it so the dashboard can still copy them, and the README claim that no token is ever written to disk is corrected to say what the code actually does. The HMAC key file is created 0600 instead of inheriting the default mask.
180 lignes
6.6 KiB
C#
180 lignes
6.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.ShareLinks.Models;
|
|
using Jellyfin.Plugin.ShareLinks.Storage;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Library;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.ShareLinks.Services;
|
|
|
|
/// <summary>Cleanly expires links and tears down temporary guest state.</summary>
|
|
public sealed class ShareLinkCleanupService : IShareLinkCleanupService
|
|
{
|
|
private readonly ShareLinkStore _store;
|
|
private readonly ILibraryManager _libraryManager;
|
|
private readonly ItemTagService _itemTagService;
|
|
private readonly JellyfinGuestUserService _guestUserService;
|
|
private readonly ILogger<ShareLinkCleanupService> _logger;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ShareLinkCleanupService"/> class.</summary>
|
|
public ShareLinkCleanupService(
|
|
ShareLinkStore store,
|
|
ILibraryManager libraryManager,
|
|
ItemTagService itemTagService,
|
|
JellyfinGuestUserService guestUserService,
|
|
ILogger<ShareLinkCleanupService> logger)
|
|
{
|
|
_store = store;
|
|
_libraryManager = libraryManager;
|
|
_itemTagService = itemTagService;
|
|
_guestUserService = guestUserService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task CleanupAsync(CancellationToken cancellationToken)
|
|
{
|
|
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
|
|
foreach (var record in records)
|
|
{
|
|
await CleanupRecordInternalAsync(record, records, false, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>Revokes a specific share link and immediately runs teardown.</summary>
|
|
public async Task<ShareLinkRecord?> RevokeAsync(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var record = await _store.GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
|
|
if (record is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
record.Status = ShareLinkStatus.Revoked;
|
|
record.CleanupError = null;
|
|
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
|
|
|
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
|
|
return await CleanupRecordInternalAsync(record, records, true, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>Runs cleanup for one record by id.</summary>
|
|
public async Task CleanupRecordAsync(Guid id, bool force, CancellationToken cancellationToken)
|
|
{
|
|
var record = await _store.GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
|
|
if (record is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
|
|
await CleanupRecordInternalAsync(record, records, force, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<ShareLinkRecord> CleanupRecordInternalAsync(
|
|
ShareLinkRecord record,
|
|
IReadOnlyList<ShareLinkRecord> allRecords,
|
|
bool force,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
record.CleanupAttempts += 1;
|
|
var now = DateTimeOffset.UtcNow;
|
|
var shouldExpire = record.ExpiresAtUtc <= now && record.Status is not ShareLinkStatus.Expired and not ShareLinkStatus.Revoked;
|
|
if (shouldExpire)
|
|
{
|
|
record.Status = ShareLinkStatus.Expired;
|
|
}
|
|
|
|
var shouldTeardown = force
|
|
|| record.Status is ShareLinkStatus.Expired
|
|
|| record.Status is ShareLinkStatus.Revoked
|
|
|| record.Status is ShareLinkStatus.Failed;
|
|
|
|
if (!shouldTeardown)
|
|
{
|
|
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
|
return record;
|
|
}
|
|
|
|
// The share URL carries the raw token, and it is kept on the record only so
|
|
// the dashboard can offer "copy" while the link is still usable. Once the
|
|
// link is torn down the token is dead weight, so drop it rather than leave
|
|
// it sitting in the store for good.
|
|
record.ShareUrl = null;
|
|
|
|
var errors = new List<string>();
|
|
try
|
|
{
|
|
await _guestUserService.DisableGuestUserAsync(record, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors.Add($"disable:{ex.Message}");
|
|
_logger.LogWarning(ex, "ShareLinks: failed to disable guest user for record {RecordId}.", record.Id);
|
|
}
|
|
|
|
try
|
|
{
|
|
await _guestUserService.DeleteGuestUserAsync(record, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors.Add($"delete:{ex.Message}");
|
|
_logger.LogWarning(ex, "ShareLinks: failed to delete guest user for record {RecordId}.", record.Id);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(record.AllowedTag) && !IsTagStillInUse(record, allRecords, now))
|
|
{
|
|
var item = TryGetItem(record.ItemId);
|
|
if (item is not null)
|
|
{
|
|
try
|
|
{
|
|
var removed = await _itemTagService.RemoveTagTreeAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false);
|
|
record.MetadataTouched |= removed;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors.Add($"tag:{ex.Message}");
|
|
_logger.LogWarning(ex, "ShareLinks: failed to remove tag {Tag} from record {RecordId}.", record.AllowedTag, record.Id);
|
|
}
|
|
}
|
|
}
|
|
|
|
record.CleanupError = errors.Count == 0 ? null : string.Join(" | ", errors);
|
|
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
|
return record;
|
|
}
|
|
|
|
private BaseItem? TryGetItem(string itemId)
|
|
{
|
|
if (!Guid.TryParse(itemId, out var id))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return _libraryManager.GetItemById(id);
|
|
}
|
|
|
|
private static bool IsTagStillInUse(ShareLinkRecord record, IReadOnlyList<ShareLinkRecord> allRecords, DateTimeOffset now)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(record.AllowedTag))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return allRecords.Any(other =>
|
|
other.Id != record.Id
|
|
&& string.Equals(other.AllowedTag, record.AllowedTag, StringComparison.OrdinalIgnoreCase)
|
|
&& other.ExpiresAtUtc > now
|
|
&& other.Status is ShareLinkStatus.Pending
|
|
or ShareLinkStatus.Active
|
|
or ShareLinkStatus.Redeeming
|
|
or ShareLinkStatus.Redeemed);
|
|
}
|
|
}
|