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.
347 lignes
14 KiB
C#
347 lignes
14 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.ShareLinks.Models;
|
|
using Jellyfin.Plugin.ShareLinks.Storage;
|
|
using MediaBrowser.Controller.Authentication;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Controller.Session;
|
|
using Microsoft.AspNetCore.Http;
|
|
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
|
|
{
|
|
private readonly ILibraryManager _libraryManager;
|
|
private readonly ShareLinkStore _store;
|
|
private readonly ShareTokenService _tokenService;
|
|
private readonly ItemTagService _itemTagService;
|
|
private readonly JellyfinGuestUserService _guestUserService;
|
|
private readonly ShareLinkCleanupService _cleanupService;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly ILogger<ShareLinkRedemptionService> _logger;
|
|
private readonly SemaphoreSlim _redeemGate = new(1, 1);
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary>
|
|
public ShareLinkRedemptionService(
|
|
ILibraryManager libraryManager,
|
|
ShareLinkStore store,
|
|
ShareTokenService tokenService,
|
|
ItemTagService itemTagService,
|
|
JellyfinGuestUserService guestUserService,
|
|
ShareLinkCleanupService cleanupService,
|
|
ISessionManager sessionManager,
|
|
ILogger<ShareLinkRedemptionService> logger)
|
|
{
|
|
_libraryManager = libraryManager;
|
|
_store = store;
|
|
_tokenService = tokenService;
|
|
_itemTagService = itemTagService;
|
|
_guestUserService = guestUserService;
|
|
_cleanupService = cleanupService;
|
|
_sessionManager = sessionManager;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <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
|
|
// the same one-use token would otherwise both mint a guest session.
|
|
await _redeemGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
return await RedeemInternalAsync(rawToken, request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_redeemGate.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>Runs a single redemption; callers must hold the redemption gate.</summary>
|
|
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 new ShareLinkRedemptionResult();
|
|
}
|
|
|
|
var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
|
|
if (record is 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 new ShareLinkRedemptionResult();
|
|
}
|
|
|
|
if (record.Status == ShareLinkStatus.Revoked || record.Status == ShareLinkStatus.Failed)
|
|
{
|
|
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 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);
|
|
return new ShareLinkRedemptionResult();
|
|
}
|
|
|
|
var item = _libraryManager.GetItemById(itemId);
|
|
if (item is null)
|
|
{
|
|
await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false);
|
|
return new ShareLinkRedemptionResult();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(record.AllowedTag))
|
|
{
|
|
await _itemTagService.EnsureTagTreeAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false);
|
|
record.MetadataTouched = true;
|
|
}
|
|
|
|
// Jellyfin logs out any existing session for the same user and device id,
|
|
// so every viewer of a multi-use link needs a device id of their own or
|
|
// each new arrival would kick the previous one off. A one-use link has a
|
|
// single viewer and keeps a stable id.
|
|
if (!record.OneUse || string.IsNullOrWhiteSpace(record.DeviceId))
|
|
{
|
|
record.DeviceId = Guid.NewGuid().ToString("N");
|
|
}
|
|
|
|
record.Status = ShareLinkStatus.Redeeming;
|
|
record.CleanupError = null;
|
|
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
|
|
|
// The account still needs a password so it can never be authenticated with a blank
|
|
// login; it is generated fresh on every redemption and is never stored or sent
|
|
// anywhere. The browser only ever receives a server-minted session token.
|
|
var password = JellyfinGuestUserService.GeneratePassword();
|
|
if (string.IsNullOrWhiteSpace(record.GuestUserName))
|
|
{
|
|
record.GuestUserName = JellyfinGuestUserService.BuildGuestUsername(record);
|
|
}
|
|
|
|
AuthenticationResult authResult;
|
|
try
|
|
{
|
|
var user = await _guestUserService.EnsureGuestUserAsync(record, password, cancellationToken).ConfigureAwait(false);
|
|
record.GuestUserId = user.Id;
|
|
record.GuestUserName = user.Username;
|
|
|
|
authResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
|
|
{
|
|
Username = record.GuestUserName,
|
|
UserId = record.GuestUserId.Value,
|
|
App = "ShareLinks",
|
|
AppVersion = "1.0.0",
|
|
DeviceId = record.DeviceId,
|
|
DeviceName = "ShareLinks",
|
|
RemoteEndPoint = request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
|
}).ConfigureAwait(false);
|
|
|
|
record.RedeemedAtUtc ??= now;
|
|
record.Status = ShareLinkStatus.Redeemed;
|
|
record.CleanupError = null;
|
|
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (MediaBrowser.Controller.Net.SecurityException ex)
|
|
{
|
|
// 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);
|
|
return new ShareLinkRedemptionResult { AtCapacity = true };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
record.Status = ShareLinkStatus.Failed;
|
|
record.CleanupError = ex.Message;
|
|
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 new ShareLinkRedemptionResult();
|
|
}
|
|
|
|
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;
|
|
record.CleanupError = reason;
|
|
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
|
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task HandleFailureAsync(ShareLinkRecord record, string reason, CancellationToken cancellationToken)
|
|
{
|
|
record.Status = ShareLinkStatus.Failed;
|
|
record.CleanupError = reason;
|
|
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
|
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task TryCleanupAsync(ShareLinkRecord record, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await _cleanupService.CleanupRecordAsync(record.Id, true, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "ShareLinks: cleanup after failed redemption did not complete for record {RecordId}.", record.Id);
|
|
}
|
|
}
|
|
|
|
private static string BuildBootstrapHtml(HttpRequest request, AuthenticationResult authResult, Guid itemId)
|
|
{
|
|
var pathBase = request.PathBase.Value ?? string.Empty;
|
|
var redirectUrl = $"{pathBase}/web/index.html#/details?id={Uri.EscapeDataString(itemId.ToString("D"))}";
|
|
|
|
var accessTokenJson = JsonSerializer.Serialize(authResult.AccessToken);
|
|
var userIdJson = JsonSerializer.Serialize(authResult.User.Id.ToString("N"));
|
|
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
|
|
var infoUrlJson = JsonSerializer.Serialize($"{pathBase}/System/Info/Public");
|
|
var pathBaseJson = JsonSerializer.Serialize(pathBase);
|
|
|
|
return $$"""
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Signing in...</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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div>Signing you in...</div>
|
|
<div class="muted" id="status">Preparing temporary access.</div>
|
|
</main>
|
|
<script>
|
|
(async () => {
|
|
const redirectUrl = {{redirectUrlJson}};
|
|
const accessToken = {{accessTokenJson}};
|
|
const userId = {{userIdJson}};
|
|
|
|
document.getElementById("status").textContent = "Opening your title.";
|
|
|
|
const info = await fetch({{infoUrlJson}}, {
|
|
credentials: "same-origin",
|
|
headers: { "Accept": "application/json" }
|
|
}).then((r) => r.json());
|
|
|
|
const serverAddress = window.location.origin + {{pathBaseJson}};
|
|
const credentials = {
|
|
Servers: [
|
|
{
|
|
ManualAddress: serverAddress,
|
|
manualAddressOnly: true,
|
|
Name: info.ServerName || "Jellyfin",
|
|
Id: info.Id,
|
|
LastConnectionMode: 1,
|
|
AccessToken: accessToken,
|
|
UserId: userId,
|
|
DateLastAccessed: Date.now()
|
|
}
|
|
]
|
|
};
|
|
|
|
try {
|
|
localStorage.setItem("jellyfin_credentials", JSON.stringify(credentials));
|
|
} catch (_) {
|
|
// If storage is blocked the redirect lands on the login screen.
|
|
}
|
|
|
|
window.location.replace(redirectUrl + "&serverId=" + encodeURIComponent(info.Id));
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
document.getElementById("status").textContent = "Sign-in failed.";
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
""";
|
|
}
|
|
}
|