cap how many people can watch one multi-use link at once
New setting, ten by default, zero for no limit. Single-use links are unaffected, they are one viewer by definition. The catch is what happens at the ceiling. Jellyfin throws SecurityException once a user is at MaxActiveSessions, and that was landing in the generic handler, which marks the record failed and runs cleanup, which deletes the guest account. So without care, adding a ceiling would mean the eleventh person to open a link kicks out the ten already watching and destroys the link. Capacity is caught separately now: the record goes back to the state it was in, nothing is torn down, and the new arrival gets a 503 page inviting them to try again. Worth being honest that this caps how many people can start watching at once, not how many ever get in: each redemption issues its own session token that keeps working until the link is revoked or expires. Revoke is still the hard stop. README picks up the multi-use option, the new setting, and a section on what a multi-use link does and does not protect, plus the known limits around the token in the query string, the unthrottled redeem endpoint, and the tag being hidden in the web UI only.
Cette révision appartient à :
@@ -187,9 +187,9 @@ public sealed class JellyfinGuestUserService
|
||||
EnabledFolders = Array.Empty<Guid>(),
|
||||
EnablePublicSharing = false,
|
||||
LoginAttemptsBeforeLockout = -1,
|
||||
// One viewer for a one-use link. A multi-use link needs a session per
|
||||
// viewer, and 0 is how Jellyfin spells "no limit" in its session check.
|
||||
MaxActiveSessions = record.OneUse ? 1 : 0,
|
||||
// One viewer for a one-use link. A multi-use link gets the configured
|
||||
// ceiling, where 0 is how Jellyfin spells "no limit" in its session check.
|
||||
MaxActiveSessions = record.OneUse ? 1 : Math.Max(config.MaxConcurrentViewers, 0),
|
||||
BlockUnratedItems = Array.Empty<UnratedItem>()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Security;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -13,6 +14,19 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.ShareLinks.Services;
|
||||
|
||||
/// <summary>Outcome of a redemption attempt.</summary>
|
||||
public sealed class ShareLinkRedemptionResult
|
||||
{
|
||||
/// <summary>Gets the bootstrap HTML when a session was minted, otherwise null.</summary>
|
||||
public string? Html { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the link is valid but already has as many
|
||||
/// viewers as it is allowed to have.
|
||||
/// </summary>
|
||||
public bool AtCapacity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Handles public share-link redemption and the bootstrap HTML response.</summary>
|
||||
public sealed class ShareLinkRedemptionService
|
||||
{
|
||||
@@ -47,8 +61,8 @@ public sealed class ShareLinkRedemptionService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Redeems a token and returns the bootstrap HTML, or null if the token is unusable.</summary>
|
||||
public async Task<string?> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
|
||||
/// <summary>Redeems a token and returns the redemption result.</summary>
|
||||
public async Task<ShareLinkRedemptionResult> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// One redemption at a time: the status checks below and the status write
|
||||
// that follows them are not atomic, so two requests arriving together with
|
||||
@@ -65,50 +79,50 @@ public sealed class ShareLinkRedemptionService
|
||||
}
|
||||
|
||||
/// <summary>Runs a single redemption; callers must hold the redemption gate.</summary>
|
||||
private async Task<string?> RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
|
||||
private async Task<ShareLinkRedemptionResult> RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false);
|
||||
if (tokenHash is null)
|
||||
{
|
||||
return null;
|
||||
return new ShareLinkRedemptionResult();
|
||||
}
|
||||
|
||||
var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return null;
|
||||
return new ShareLinkRedemptionResult();
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (record.ExpiresAtUtc <= now)
|
||||
{
|
||||
await HandleTerminalRecordAsync(record, ShareLinkStatus.Expired, "Share link has expired.", cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
return new ShareLinkRedemptionResult();
|
||||
}
|
||||
|
||||
if (record.Status == ShareLinkStatus.Revoked || record.Status == ShareLinkStatus.Failed)
|
||||
{
|
||||
return null;
|
||||
return new ShareLinkRedemptionResult();
|
||||
}
|
||||
|
||||
// Checked before any library write: re-tagging the whole tree on every hit
|
||||
// to an already-spent link would be a pointless metadata write storm.
|
||||
if (record.OneUse && record.Status == ShareLinkStatus.Redeemed)
|
||||
{
|
||||
return null;
|
||||
return new ShareLinkRedemptionResult();
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(record.ItemId, out var itemId))
|
||||
{
|
||||
await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
return new ShareLinkRedemptionResult();
|
||||
}
|
||||
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
if (item is null)
|
||||
{
|
||||
await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
return new ShareLinkRedemptionResult();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(record.AllowedTag))
|
||||
@@ -162,6 +176,17 @@ public sealed class ShareLinkRedemptionService
|
||||
record.CleanupError = null;
|
||||
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (SecurityException ex)
|
||||
{
|
||||
// The link is fine, the guest account has simply reached its viewer
|
||||
// ceiling. Leave the record and the guest alone: marking this failed
|
||||
// would tear down the account and throw out everyone already watching.
|
||||
record.Status = record.RedeemedAtUtc.HasValue ? ShareLinkStatus.Redeemed : ShareLinkStatus.Active;
|
||||
record.CleanupError = null;
|
||||
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(ex, "ShareLinks: record {RecordId} is at its viewer ceiling; turning a viewer away.", record.Id);
|
||||
return new ShareLinkRedemptionResult { AtCapacity = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
record.Status = ShareLinkStatus.Failed;
|
||||
@@ -169,10 +194,10 @@ public sealed class ShareLinkRedemptionService
|
||||
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning(ex, "ShareLinks: failed to prepare guest session for record {RecordId}.", record.Id);
|
||||
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
return new ShareLinkRedemptionResult();
|
||||
}
|
||||
|
||||
return BuildBootstrapHtml(request, authResult, itemId);
|
||||
return new ShareLinkRedemptionResult { Html = BuildBootstrapHtml(request, authResult, itemId) };
|
||||
}
|
||||
|
||||
private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken)
|
||||
|
||||
Référencer dans un nouveau ticket
Bloquer un utilisateur