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 à :
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+33
-1
@@ -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 |
|
||||
|
||||
Référencer dans un nouveau ticket
Bloquer un utilisateur