ShareLinks plugin: guest share links for Jellyfin

Includes fix for redemption failing with DbUpdateConcurrencyException:
change the guest password before applying the user policy, since
UpdatePolicyAsync bumps the user's EF concurrency token and a stale
instance then breaks ChangePassword.
Cette révision appartient à :
Franciskid
2026-07-06 18:21:22 +02:00
révision c29ea7f20c
26 fichiers modifiés avec 3804 ajouts et 0 suppressions
+374
Voir le fichier
@@ -0,0 +1,374 @@
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;
/// <summary>Request body for ShareLinks admin creation.</summary>
public sealed class ShareLinkCreateRequest
{
/// <summary>Gets or sets the Jellyfin item id.</summary>
public string? ItemId { get; set; }
/// <summary>Gets or sets an optional expiry in hours.</summary>
public int? ExpiryHours { get; set; }
/// <summary>Gets or sets whether the link may be redeemed once only.</summary>
public bool? OneUse { get; set; }
}
/// <summary>Admin response for a created ShareLinks record.</summary>
public sealed class ShareLinkCreateResponse
{
/// <summary>Gets or sets the raw share URL.</summary>
public string ShareUrl { get; set; } = string.Empty;
/// <summary>Gets or sets the created record snapshot.</summary>
public ShareLinkAdminRecordDto Record { get; set; } = new();
}
/// <summary>DTO returned by admin list and revoke endpoints.</summary>
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; }
}
/// <summary>Guest session state returned to the web client.</summary>
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; }
}
/// <summary>ShareLinks API surface.</summary>
[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<ShareLinksController> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinksController"/> class.</summary>
public ShareLinksController(
ILibraryManager libraryManager,
ShareLinkCreationService creationService,
ShareLinkCleanupService cleanupService,
ShareLinkRedemptionService redemptionService,
ShareLinkStore store,
ILogger<ShareLinksController> logger)
{
_libraryManager = libraryManager;
_creationService = creationService;
_cleanupService = cleanupService;
_redemptionService = redemptionService;
_store = store;
_logger = logger;
}
private static PluginConfiguration Config => Plugin.Instance!.Configuration;
/// <summary>Serves the client-side ShareLinks script.</summary>
[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");
}
/// <summary>Creates a new share link for an item.</summary>
[HttpPost("Admin/Create")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult<ShareLinkCreateResponse>> 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." });
}
}
/// <summary>Lists all share links for administrators.</summary>
[HttpGet("Admin/List")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult<IEnumerable<ShareLinkAdminRecordDto>>> List(CancellationToken cancellationToken)
{
SetNoStoreHeaders();
if (!User.IsInRole("Administrator"))
{
return Forbid();
}
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
return Ok(records.Select(ToDto).ToArray());
}
/// <summary>Revokes a share link and triggers cleanup.</summary>
[HttpPost("Admin/Revoke/{id:guid}")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult<ShareLinkAdminRecordDto>> 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));
}
/// <summary>Returns the guest session state for the current authenticated user.</summary>
[HttpGet("GuestState")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult<ShareLinkGuestStateDto>> 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
});
}
/// <summary>Redeems a share link token and returns the bootstrap login page.</summary>
[HttpGet("Redeem")]
[AllowAnonymous]
public async Task<ActionResult> 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)}";
}
}