using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Claims; using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.ShareLinks.Configuration; using Jellyfin.Plugin.ShareLinks.Models; using Jellyfin.Plugin.ShareLinks.Services; using Jellyfin.Plugin.ShareLinks.Storage; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.ShareLinks.Api; /// Request body for ShareLinks admin creation. public sealed class ShareLinkCreateRequest { /// Gets or sets the Jellyfin item id. public string? ItemId { get; set; } /// Gets or sets an optional expiry in hours. public int? ExpiryHours { get; set; } /// Gets or sets whether the link may be redeemed once only. public bool? OneUse { get; set; } } /// Admin response for a created ShareLinks record. public sealed class ShareLinkCreateResponse { /// Gets or sets the raw share URL. public string ShareUrl { get; set; } = string.Empty; /// Gets or sets the created record snapshot. public ShareLinkAdminRecordDto Record { get; set; } = new(); } /// DTO returned by admin list and revoke endpoints. public sealed class ShareLinkAdminRecordDto { public Guid Id { get; set; } public string ItemId { get; set; } = string.Empty; public string ItemNameSnapshot { get; set; } = string.Empty; public string? LibraryId { get; set; } public Guid? CreatedByUserId { get; set; } public DateTimeOffset CreatedAtUtc { get; set; } public DateTimeOffset? RedeemedAtUtc { get; set; } public DateTimeOffset ExpiresAtUtc { get; set; } public ShareLinkStatus Status { get; set; } public Guid? GuestUserId { get; set; } public string? GuestUserName { get; set; } public string? AllowedTag { get; set; } public bool OneUse { get; set; } public bool MetadataTouched { get; set; } public int CleanupAttempts { get; set; } public string? CleanupError { get; set; } } /// Guest session state returned to the web client. public sealed class ShareLinkGuestStateDto { public bool IsGuest { get; set; } public string? AllowedItemId { get; set; } public Guid? ShareId { get; set; } public DateTimeOffset? ExpiresAtUtc { get; set; } public bool LockdownEnabled { get; set; } } /// ShareLinks API surface. [ApiController] [Route("ShareLinks")] public sealed class ShareLinksController : ControllerBase { private readonly ILibraryManager _libraryManager; private readonly ShareLinkCreationService _creationService; private readonly ShareLinkCleanupService _cleanupService; private readonly ShareLinkRedemptionService _redemptionService; private readonly ShareLinkStore _store; private readonly ILogger _logger; /// Initializes a new instance of the class. public ShareLinksController( ILibraryManager libraryManager, ShareLinkCreationService creationService, ShareLinkCleanupService cleanupService, ShareLinkRedemptionService redemptionService, ShareLinkStore store, ILogger logger) { _libraryManager = libraryManager; _creationService = creationService; _cleanupService = cleanupService; _redemptionService = redemptionService; _store = store; _logger = logger; } private static PluginConfiguration Config => Plugin.Instance!.Configuration; /// Serves the client-side ShareLinks script. [HttpGet("ClientScript")] [AllowAnonymous] public ActionResult ClientScript() { SetNoStoreHeaders(); var assembly = typeof(ShareLinksController).Assembly; var resourceName = assembly.GetManifestResourceNames() .FirstOrDefault(name => name.EndsWith(".Web.sharelinks.js", StringComparison.OrdinalIgnoreCase)); if (resourceName is null) { return NotFound(); } using var stream = assembly.GetManifestResourceStream(resourceName); if (stream is null) { return NotFound(); } using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); return Content(reader.ReadToEnd(), "application/javascript; charset=utf-8"); } /// Creates a new share link for an item. [HttpPost("Admin/Create")] [Authorize(AuthenticationSchemes = "CustomAuthentication")] public async Task> Create([FromBody] ShareLinkCreateRequest request, CancellationToken cancellationToken) { SetNoStoreHeaders(); if (!User.IsInRole("Administrator")) { return Forbid(); } var config = Config; if (!config.Enabled) { return StatusCode(503, new { error = "ShareLinks is disabled." }); } if (request is null || string.IsNullOrWhiteSpace(request.ItemId)) { return BadRequest(new { error = "Missing itemId." }); } if (!Guid.TryParse(request.ItemId, out var itemId)) { return BadRequest(new { error = "Invalid itemId." }); } var expiryHours = request.ExpiryHours ?? config.DefaultExpiryHours; if (expiryHours <= 0) { return BadRequest(new { error = "Expiry must be positive." }); } var effectiveMaxExpiryHours = Math.Max(config.MaxExpiryHours, 720); if (expiryHours > effectiveMaxExpiryHours) { return BadRequest(new { error = $"Expiry exceeds the configured maximum of {effectiveMaxExpiryHours} hours." }); } var item = _libraryManager.GetItemById(itemId); if (item is null) { return NotFound(new { error = "Item not found." }); } try { var creatorUserId = GetCurrentUserId(); var oneUse = request.OneUse ?? config.OneUseDefault; var creation = await _creationService.CreateAsync(item, creatorUserId, expiryHours, oneUse, cancellationToken).ConfigureAwait(false); var shareUrl = BuildShareUrl(Request, creation.RawToken); return Ok(new ShareLinkCreateResponse { ShareUrl = shareUrl, Record = ToDto(creation.Record) }); } catch (Exception ex) { _logger.LogWarning(ex, "ShareLinks: create failed for item {ItemId}.", itemId); return StatusCode(500, new { error = "Failed to create share link." }); } } /// Lists all share links for administrators. [HttpGet("Admin/List")] [Authorize(AuthenticationSchemes = "CustomAuthentication")] public async Task>> List(CancellationToken cancellationToken) { SetNoStoreHeaders(); if (!User.IsInRole("Administrator")) { return Forbid(); } var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false); return Ok(records.Select(ToDto).ToArray()); } /// Revokes a share link and triggers cleanup. [HttpPost("Admin/Revoke/{id:guid}")] [Authorize(AuthenticationSchemes = "CustomAuthentication")] public async Task> Revoke(Guid id, CancellationToken cancellationToken) { SetNoStoreHeaders(); if (!User.IsInRole("Administrator")) { return Forbid(); } var record = await _cleanupService.RevokeAsync(id, cancellationToken).ConfigureAwait(false); if (record is null) { return NotFound(new { error = "Share link not found." }); } return Ok(ToDto(record)); } /// Returns the guest session state for the current authenticated user. [HttpGet("GuestState")] [Authorize(AuthenticationSchemes = "CustomAuthentication")] public async Task> GuestState(CancellationToken cancellationToken) { SetNoStoreHeaders(); var config = Config; var currentUserId = GetCurrentUserId(); var currentUserName = GetCurrentUserName(); if (currentUserId != Guid.Empty || !string.IsNullOrWhiteSpace(currentUserName)) { var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false); var match = records.FirstOrDefault(record => !IsExpired(record) && IsGuestSessionStatus(record.Status) && ( (currentUserId != Guid.Empty && record.GuestUserId.HasValue && record.GuestUserId.Value == currentUserId) || (!string.IsNullOrWhiteSpace(currentUserName) && !string.IsNullOrWhiteSpace(record.GuestUserName) && string.Equals(record.GuestUserName, currentUserName, StringComparison.OrdinalIgnoreCase)) )); if (match is not null) { return Ok(new ShareLinkGuestStateDto { IsGuest = true, AllowedItemId = match.ItemId, ShareId = match.Id, ExpiresAtUtc = match.ExpiresAtUtc, LockdownEnabled = config.GuestModeLockdownEnabled }); } } return Ok(new ShareLinkGuestStateDto { IsGuest = false, LockdownEnabled = config.GuestModeLockdownEnabled }); } /// Redeems a share link token and returns the bootstrap login page. [HttpGet("Redeem")] [AllowAnonymous] public async Task Redeem([FromQuery(Name = "t")] string? token, CancellationToken cancellationToken) { SetNoStoreHeaders(); if (string.IsNullOrWhiteSpace(token)) { return NotFound(); } var html = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false); if (html is null) { return NotFound(); } return Content(html, "text/html; charset=utf-8"); } private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record) { return new ShareLinkAdminRecordDto { Id = record.Id, ItemId = record.ItemId, ItemNameSnapshot = record.ItemNameSnapshot, LibraryId = record.LibraryId, CreatedByUserId = record.CreatedByUserId, CreatedAtUtc = record.CreatedAtUtc, RedeemedAtUtc = record.RedeemedAtUtc, ExpiresAtUtc = record.ExpiresAtUtc, Status = record.Status, GuestUserId = record.GuestUserId, GuestUserName = record.GuestUserName, AllowedTag = record.AllowedTag, OneUse = record.OneUse, MetadataTouched = record.MetadataTouched, CleanupAttempts = record.CleanupAttempts, CleanupError = record.CleanupError }; } private Guid GetCurrentUserId() { var claim = User.FindFirst("Jellyfin-UserId")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; return Guid.TryParse(claim, out var id) ? id : Guid.Empty; } private string? GetCurrentUserName() { return User.FindFirst("Jellyfin-UserName")?.Value ?? User.FindFirst(ClaimTypes.Name)?.Value ?? User.Identity?.Name; } private static bool IsExpired(ShareLinkRecord record) { return record.ExpiresAtUtc <= DateTimeOffset.UtcNow; } private static bool IsGuestSessionStatus(ShareLinkStatus status) { return status is ShareLinkStatus.Active or ShareLinkStatus.Redeeming or ShareLinkStatus.Redeemed; } private void SetNoStoreHeaders() { Response.Headers["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate"; Response.Headers["Pragma"] = "no-cache"; } private static string BuildShareUrl(Microsoft.AspNetCore.Http.HttpRequest request, string rawToken) { var config = Config; var baseUrl = string.IsNullOrWhiteSpace(config.PublicBaseUrlOverride) ? $"{request.Scheme}://{request.Host}{request.PathBase}" : config.PublicBaseUrlOverride.TrimEnd('/'); return $"{baseUrl.TrimEnd('/')}/ShareLinks/Redeem?t={Uri.EscapeDataString(rawToken)}"; } }