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;
/// 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();
}
// 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) };
}
///
/// 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.
///
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 $$"""
Signing in...