refuse an over-the-ceiling viewer without destroying the link

The ceiling shipped in 1.0.3.0 did the opposite of its job. Jellyfin throws
MediaBrowser.Controller.Net.SecurityException when a user is at their session
limit, and the catch that was meant to handle it named System.Security's type of
the same name, so it never matched. The exception fell through to the failure
path, which marks the record failed and runs cleanup, which deletes the guest
account. Live result with the ceiling at 3: the fourth viewer got a dead-link
page, the three already watching were kicked out, and the link was gone.

Rather than swap one exception type for another and trust it, the over-the-ceiling
viewer is now turned away before anything is written at all: no tag work, no
status change, the guest account untouched. The check mirrors what Jellyfin does
when it creates a session, counting sessions for the guest against the ceiling.
The typed catch stays as a backstop for the race between the check and the call,
and it now names the right type.

A link that has never been redeemed has no guest yet, so it can never be at a
ceiling, and 0 still means no limit.
Cette révision appartient à :
Franciskid
2026-07-26 21:45:48 +02:00
Parent fd1e4a8d3c
révision 01e2235c02
3 fichiers modifiés avec 52 ajouts et 11 suppressions
+3 -3
Voir le fichier
@@ -6,9 +6,9 @@
<LangVersion>latest</LangVersion>
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
<Version>1.0.3.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<Version>1.0.3.1</Version>
<AssemblyVersion>1.0.3.1</AssemblyVersion>
<FileVersion>1.0.3.1</FileVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>disable</ImplicitUsings>
+47 -6
Voir le fichier
@@ -1,5 +1,5 @@
using System;
using System.Security;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -112,6 +112,19 @@ public sealed class ShareLinkRedemptionService
return new ShareLinkRedemptionResult();
}
// Turn an over-the-ceiling viewer away before anything is written: no tag
// work, no status change, no guest account touched. Jellyfin enforces the
// same limit itself when the session is created, but it does so by throwing,
// and a throw here would land in the failure path below and tear the whole
// share down on the people already watching.
if (IsAtViewerCeiling(record))
{
_logger.LogInformation(
"ShareLinks: record {RecordId} is at its viewer ceiling; turning a viewer away.",
record.Id);
return new ShareLinkRedemptionResult { AtCapacity = true };
}
if (!Guid.TryParse(record.ItemId, out var itemId))
{
await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false);
@@ -176,15 +189,17 @@ public sealed class ShareLinkRedemptionService
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
}
catch (SecurityException ex)
catch (MediaBrowser.Controller.Net.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.
// Backstop for the race between the ceiling check above and this call.
// Jellyfin's own type, NOT System.Security.SecurityException. The link is
// fine, so leave the record and the guest exactly as they were: marking
// this failed would tear the account down and throw out everyone already
// watching.
_logger.LogInformation(ex, "ShareLinks: record {RecordId} hit its viewer ceiling while creating the session.", record.Id);
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)
@@ -200,6 +215,32 @@ public sealed class ShareLinkRedemptionService
return new ShareLinkRedemptionResult { Html = BuildBootstrapHtml(request, authResult, itemId) };
}
/// <summary>
/// Returns true when the share already has as many viewers watching as it is
/// allowed. Mirrors the limit Jellyfin applies when it creates a session, so we
/// can refuse politely instead of letting it throw.
/// </summary>
private bool IsAtViewerCeiling(ShareLinkRecord record)
{
if (!record.GuestUserId.HasValue)
{
// Nobody has redeemed this link yet, so nothing can be at a ceiling.
return false;
}
var ceiling = record.OneUse
? 1
: Math.Max(Plugin.Instance?.Configuration.MaxConcurrentViewers ?? 0, 0);
if (ceiling < 1)
{
// 0 means no limit, the same way Jellyfin reads it.
return false;
}
var guestUserId = record.GuestUserId.Value;
return _sessionManager.Sessions.Count(session => session.UserId.Equals(guestUserId)) >= ceiling;
}
private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken)
{
record.Status = terminalStatus;
+2 -2
Voir le fichier
@@ -1,12 +1,12 @@
{
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
"name": "ShareLinks",
"version": "1.0.3.0",
"version": "1.0.3.1",
"targetAbi": "10.11.0.0",
"framework": "net9.0",
"owner": "Franciskid",
"overview": "Secure expiring guest-share links for Jellyfin items.",
"description": "Adds secure, expiring share links for Jellyfin items with JSON-backed storage, token hashing, and cleanup scaffolding.",
"category": "General",
"timestamp": "2026-07-26T19:30:00.0000000Z"
"timestamp": "2026-07-26T20:00:00.0000000Z"
}