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.
306 lignes
12 KiB
C#
306 lignes
12 KiB
C#
using System;
|
|
using System.Security;
|
|
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();
|
|
}
|
|
|
|
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 (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;
|
|
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) };
|
|
}
|
|
|
|
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>
|
|
""";
|
|
}
|
|
}
|