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 à :
Franciskid
2026-07-26 21:22:20 +02:00
Parent f0b9e8351b
révision 224a79f7e5
7 fichiers modifiés avec 147 ajouts et 21 suppressions
+47 -3
Voir le fichier
@@ -326,13 +326,18 @@ public sealed class ShareLinksController : ControllerBase
return LinkUnavailablePage(Request);
}
var html = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false);
if (html is null)
var result = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false);
if (result.AtCapacity)
{
return LinkBusyPage();
}
if (result.Html is null)
{
return LinkUnavailablePage(Request);
}
return Content(html, "text/html; charset=utf-8");
return Content(result.Html, "text/html; charset=utf-8");
}
private static ContentResult LinkUnavailablePage(HttpRequest request)
@@ -380,6 +385,45 @@ setTimeout(function () { window.location.replace({{redirectUrlJson}}); }, 4000);
};
}
/// <summary>
/// Served when a multi-use link has as many viewers as it is allowed. The link
/// itself is still good, so this deliberately invites a retry instead of
/// looking like a dead link.
/// </summary>
private static ContentResult LinkBusyPage()
{
var html = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Too many viewers</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; }
main { max-width: 36rem; padding: 2rem; }
.muted { color: #9ca3af; }
a { color: #60a5fa; }
</style>
</head>
<body>
<main>
<div>This link is being watched by as many people as it allows right now.</div>
<div class="muted">Ce lien est deja utilise par autant de personnes qu'il l'autorise.</div>
<p><a href="">Try again</a></p>
</main>
</body>
</html>
""";
return new ContentResult
{
StatusCode = StatusCodes.Status503ServiceUnavailable,
ContentType = "text/html; charset=utf-8",
Content = html
};
}
private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record)
{
return new ShareLinkAdminRecordDto
+6
Voir le fichier
@@ -39,6 +39,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary>Gets or sets a value indicating whether links default to one use.</summary>
public bool OneUseDefault { get; set; } = true;
/// <summary>
/// Gets or sets how many people may watch a multi-use link at the same time.
/// 0 means no limit. One-use links are always a single viewer regardless.
/// </summary>
public int MaxConcurrentViewers { get; set; } = 10;
/// <summary>Gets or sets a value indicating whether guest-mode lockdown is enabled.</summary>
public bool GuestModeLockdownEnabled { get; set; } = true;
+3 -3
Voir le fichier
@@ -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>()
};
+37 -12
Voir le fichier
@@ -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)
+7
Voir le fichier
@@ -106,6 +106,11 @@
<div class="fieldDescription checkboxFieldDescription">This only decides how the "Let several people use this link" box starts out in the create popup; you can change it for every link you make. A single-use link stops working the moment the first person opens it, and only that person keeps access until it expires. A multi-use link can be opened by everyone you send it to, for as long as it is valid.</div>
</div>
<div class="sl-field inputContainer">
<input is="emby-input" type="number" id="MaxConcurrentViewers" label="Maximum viewers per multi-use link" min="0" max="500" />
<div class="fieldDescription">How many people may watch a multi-use link at the same time. 0 means no limit. Someone arriving once the limit is reached is asked to try again later; nobody already watching is disturbed. Single-use links are always one viewer.</div>
</div>
<div class="sl-field inputContainer">
<input is="emby-input" type="number" id="DefaultExpiryHours" label="Default expiry (hours)" min="1" max="8760" />
<div class="fieldDescription">Used by the menu action when the admin accepts the default.</div>
@@ -218,6 +223,7 @@
page.querySelector('#AllowRemuxing').checked = cfg.AllowRemuxing !== false;
page.querySelector('#CleanupIntervalMinutes').value = cfg.CleanupIntervalMinutes || 60;
page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false;
page.querySelector('#MaxConcurrentViewers').value = cfg.MaxConcurrentViewers === undefined ? 10 : cfg.MaxConcurrentViewers;
page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false;
}).finally(function () {
Dashboard.hideLoadingMsg();
@@ -399,6 +405,7 @@
cfg.AllowRemuxing = page.querySelector('#AllowRemuxing').checked;
cfg.CleanupIntervalMinutes = parseInt(page.querySelector('#CleanupIntervalMinutes').value, 10) || 60;
cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked;
cfg.MaxConcurrentViewers = Math.max(parseInt(page.querySelector('#MaxConcurrentViewers').value, 10) || 0, 0);
cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked;
ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
+14 -2
Voir le fichier
@@ -2,7 +2,7 @@
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
var copyLabel = 'Copy Stream URL';
var actionLabel = 'ShareLink';
var clientVersion = '1.0.3-ui-1';
var clientVersion = '1.0.3-ui-2';
var allowedItemStorageKey = 'sharelinks.allowedItemId';
var guestClassName = 'sharelinks-guest';
var hiddenAttr = 'data-sharelinks-hidden';
@@ -67,6 +67,7 @@
pickFuture: 'Pick a time in the future.',
multiUseLabel: 'Let several people use this link',
multiUseHint: 'The link keeps working for anyone you send it to until it expires, instead of dying once the first person opens it.',
multiUseLimit: 'Up to {count} of them can watch at the same time.',
resultMultiUseNote: 'Anyone you send this link to can open it until it expires.',
cannotDetermineItem: 'Could not determine which item to share. Open the item page and retry.',
adminOnly: 'ShareLinks is available to administrators only.',
@@ -94,6 +95,7 @@
pickFuture: 'Choisissez une date dans le futur.',
multiUseLabel: 'Autoriser plusieurs personnes à utiliser ce lien',
multiUseHint: 'Le lien reste valable pour toutes les personnes à qui vous l\'envoyez jusqu\'à son expiration, au lieu de mourir dès la première ouverture.',
multiUseLimit: 'Jusqu\'a {count} d\'entre elles peuvent regarder en meme temps.',
resultMultiUseNote: 'Toutes les personnes à qui vous envoyez ce lien peuvent l\'ouvrir jusqu\'à son expiration.',
cannotDetermineItem: 'Impossible de déterminer l\'élément à partager. Ouvrez la page du média et réessayez.',
adminOnly: 'ShareLinks est réservé aux administrateurs.',
@@ -1143,7 +1145,7 @@
cancelText: t('cancel'),
toggle: {
label: t('multiUseLabel'),
hint: t('multiUseHint'),
hint: buildMultiUseHint(config),
checked: !(config && config.OneUseDefault !== false)
},
datePicker: {
@@ -1155,6 +1157,16 @@
});
}
function buildMultiUseHint(config) {
var hint = t('multiUseHint');
var limit = config ? parseInt(config.MaxConcurrentViewers, 10) : NaN;
if (Number.isFinite(limit) && limit > 0) {
hint += ' ' + t('multiUseLimit').replace('{count}', limit);
}
return hint;
}
function copyTextWhenReady(textPromise) {
if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob) {
try {