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; /// Outcome of a redemption attempt. public sealed class ShareLinkRedemptionResult { /// Gets the bootstrap HTML when a session was minted, otherwise null. public string? Html { get; init; } /// /// Gets a value indicating whether the link is valid but already has as many /// viewers as it is allowed to have. /// public bool AtCapacity { get; init; } } /// Handles public share-link redemption and the bootstrap HTML response. 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 _logger; private readonly SemaphoreSlim _redeemGate = new(1, 1); /// Initializes a new instance of the class. public ShareLinkRedemptionService( ILibraryManager libraryManager, ShareLinkStore store, ShareTokenService tokenService, ItemTagService itemTagService, JellyfinGuestUserService guestUserService, ShareLinkCleanupService cleanupService, ISessionManager sessionManager, ILogger logger) { _libraryManager = libraryManager; _store = store; _tokenService = tokenService; _itemTagService = itemTagService; _guestUserService = guestUserService; _cleanupService = cleanupService; _sessionManager = sessionManager; _logger = logger; } /// Redeems a token and returns the redemption result. public async Task 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(); } } /// Runs a single redemption; callers must hold the redemption gate. private async Task 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 $$""" Signing in...
Signing you in...
Preparing temporary access.
"""; } }