The sign-in page stored the guest's server entry with LastConnectionMode 1 (Remote) but only a ManualAddress. jellyfin-web 12 builds its ApiClient from that entry, gets no address, throws "Must supply a serverAddress" and never leaves the splash screen. Store mode 2 (Manual), which is what jellyfin-web itself writes after a normal sign-in. Also hide the Modern layout header links, user menu and drawer toggle for guests (Modern is the default in 12), and close the item action sheet on desktop when ShareLink opens its popup. Escape only closes it in the TV layout, a click on the dialog container works everywhere. Bump to 1.0.6.0.
350 lignes
14 KiB
C#
350 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 = JellyfinGuestUserService.GuestAppName,
|
|
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);
|
|
|
|
// jellyfin-web resolves the server address from LastConnectionMode; 2 (Manual) is
|
|
// what it stores after a normal sign-in. Jellyfin 12 builds its client straight from
|
|
// this entry, so Remote (1) with no RemoteAddress left the guest on the splash screen.
|
|
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: 2,
|
|
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>
|
|
""";
|
|
}
|
|
}
|