From 224a79f7e55df0af0caa017398e54567d6a35d73 Mon Sep 17 00:00:00 2001 From: Franciskid Date: Sun, 26 Jul 2026 21:22:20 +0200 Subject: [PATCH] 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. --- .../Api/ShareLinksController.cs | 50 +++++++++++++++++-- .../Configuration/PluginConfiguration.cs | 6 +++ .../Services/JellyfinGuestUserService.cs | 6 +-- .../Services/ShareLinkRedemptionService.cs | 49 +++++++++++++----- .../Web/configPage.html | 7 +++ Jellyfin.Plugin.ShareLinks/Web/sharelinks.js | 16 +++++- README.md | 34 ++++++++++++- 7 files changed, 147 insertions(+), 21 deletions(-) diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs index c37238a..096f24b 100644 --- a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs +++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs @@ -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); }; } + /// + /// 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. + /// + private static ContentResult LinkBusyPage() + { + var html = """ + + + + + + Too many viewers + + + +
+
This link is being watched by as many people as it allows right now.
+
Ce lien est deja utilise par autant de personnes qu'il l'autorise.
+

Try again

+
+ + +"""; + + return new ContentResult + { + StatusCode = StatusCodes.Status503ServiceUnavailable, + ContentType = "text/html; charset=utf-8", + Content = html + }; + } + private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record) { return new ShareLinkAdminRecordDto diff --git a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs index f4839ed..5a68fcd 100644 --- a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs @@ -39,6 +39,12 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets a value indicating whether links default to one use. public bool OneUseDefault { get; set; } = true; + /// + /// 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. + /// + public int MaxConcurrentViewers { get; set; } = 10; + /// Gets or sets a value indicating whether guest-mode lockdown is enabled. public bool GuestModeLockdownEnabled { get; set; } = true; diff --git a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs index e78cc3b..bf7b774 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs @@ -187,9 +187,9 @@ public sealed class JellyfinGuestUserService EnabledFolders = Array.Empty(), 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() }; diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs index 576f15a..6ccf55d 100644 --- a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs @@ -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; +/// Outcome of a redemption attempt. +public sealed class ShareLinkRedemptionResult +{ + /// Gets the bootstrap HTML when a session was minted, otherwise null. + public string? Html { get; init; } + + /// + /// Gets a value indicating whether the link is valid but already has as many + /// viewers as it is allowed to have. + /// + public bool AtCapacity { get; init; } +} + /// Handles public share-link redemption and the bootstrap HTML response. public sealed class ShareLinkRedemptionService { @@ -47,8 +61,8 @@ public sealed class ShareLinkRedemptionService _logger = logger; } - /// Redeems a token and returns the bootstrap HTML, or null if the token is unusable. - public async Task RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) + /// Redeems a token and returns the redemption result. + public async Task 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 } /// Runs a single redemption; callers must hold the redemption gate. - private async Task RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) + private async Task 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) diff --git a/Jellyfin.Plugin.ShareLinks/Web/configPage.html b/Jellyfin.Plugin.ShareLinks/Web/configPage.html index 8581f6f..a69f826 100644 --- a/Jellyfin.Plugin.ShareLinks/Web/configPage.html +++ b/Jellyfin.Plugin.ShareLinks/Web/configPage.html @@ -106,6 +106,11 @@
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.
+
+ +
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.
+
+
Used by the menu action when the admin accepts the default.
@@ -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); diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js index 68720b9..38fc769 100644 --- a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js +++ b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js @@ -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 { diff --git a/README.md b/README.md index 0525997..1daacb6 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ real user or handing over a login that sees everything. plugin hands you a link, copied to your clipboard. You also choose there whether the link is single use, which is the default and stops working once the first person opens it, or multi-use, which lets everyone you send it to - open it until it expires. + open it until it expires. A multi-use link has a ceiling on how many people can + watch at the same time, ten by default, and the eleventh is asked to try again + later rather than displacing anyone. 2. Behind the scenes the plugin tags the shared item with a unique, random tag and records the share. Share a series or a season and the tag is applied to the whole tree underneath it too - series, seasons and episodes - so the @@ -117,6 +119,35 @@ refuses every interactive sign-in, so the normal login page cannot be used to ge into a guest account at all, password or not. If the plugin is disabled Jellyfin falls back to its own invalid-provider handling, which refuses too. +### What a multi-use link does and does not protect + +A multi-use link is by design usable by anyone you send it to, so treat the URL +itself as the secret. Within that: + +- The tag policy is per account and the account is the same one, so every viewer + still sees exactly the shared title and nothing else. Letting more people in + does not widen what any of them can reach. +- The viewer ceiling caps how many people can *start* watching at once. It is not + a hard cap on how many people ever get in: sessions end, and each redemption + issues its own session token which keeps working until the link is revoked or + expires. If you need a hard stop, revoke the link. +- Everyone shares one temporary account, so they share playback position and + watched state on that title, and they can see each other's sessions in Jellyfin. + If that matters to you, use single-use links. +- Reaching the ceiling turns the new arrival away with a "try again" page. It does + not disturb anyone already watching, and it does not kill the link. + +### Known limits + +- The share token travels in the link's query string, so it will appear in your + reverse proxy's access log and in browser history. +- Redeeming is a public endpoint with no rate limit. Tokens are 256-bit random, so + guessing one is not realistic, but the endpoint is reachable by anyone. +- Records are kept after they expire, for audit, and are never pruned. +- The `sharelinks-` tag is hidden from non-admins in the web client only. It is + still present in the API response for anyone who looks, because that tag is what + confines the guest and it cannot be removed without removing the confinement. + ## Configuration All of these live on the plugin's dashboard page: @@ -128,6 +159,7 @@ All of these live on the plugin's dashboard page: | Guest username prefix | Prefix for the throwaway guest accounts (default `share-`) | | Allow transcoding / remuxing | Whether guest playback may transcode or remux | | Cleanup interval | How often the background cleanup runs | +| Maximum viewers per multi-use link | How many people may watch one multi-use link at the same time (default 10, 0 means no limit) | | Single use by default | How the single-use box starts out in the create popup; it is a per-link choice | | Guest lockdown | The web-client confinement described above (on by default) | | Guest hidden selectors | CSS selectors hidden from guests, to suppress other plugins' UI |