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.
375 lignes
13 KiB
C#
375 lignes
13 KiB
C#
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)}";
|
|
}
|
|
}
|