Fichiers

567 lignes
20 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
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.Common.Plugins;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
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; }
public string? ShareUrl { 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; }
public string? HiddenSelectors { get; set; }
}
/// <summary>An installed plugin, as offered in the guard's exception list.</summary>
public sealed class ShareLinkPluginDto
{
/// <summary>Gets or sets the plugin id.</summary>
public Guid Id { get; set; }
/// <summary>Gets or sets the plugin's display name.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether guests may currently reach it.</summary>
public bool AllowedForGuests { 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 IPluginManager _pluginManager;
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,
IPluginManager pluginManager,
ILogger<ShareLinksController> logger)
{
_libraryManager = libraryManager;
_creationService = creationService;
_cleanupService = cleanupService;
_redemptionService = redemptionService;
_store = store;
_pluginManager = pluginManager;
_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))
{
_logger.LogWarning("ShareLinks: create rejected, missing itemId.");
return BadRequest(new { error = "Missing itemId." });
}
if (!Guid.TryParse(request.ItemId!.Trim(), out var itemId))
{
_logger.LogWarning("ShareLinks: create rejected, itemId {ItemId} is not a GUID.", request.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 maxExpiryHours = config.MaxExpiryHours > 0 ? config.MaxExpiryHours : 720;
if (expiryHours > maxExpiryHours)
{
return BadRequest(new { error = $"Expiry exceeds the configured maximum of {maxExpiryHours} hours." });
}
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
_logger.LogWarning("ShareLinks: create rejected, item {ItemId} not found.", itemId);
return NotFound(new { error = "Item not found." });
}
if (!IsShareableItem(item))
{
_logger.LogWarning(
"ShareLinks: create rejected, item {ItemId} \"{ItemName}\" is a {ItemType}, which is not shareable media.",
itemId,
item.Name,
item.GetType().Name);
return BadRequest(new { error = "Only a video, image, and collection can be shared. Open the title's page and try again." });
}
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);
creation.Record.ShareUrl = shareUrl;
await _store.UpdateAsync(creation.Record, cancellationToken).ConfigureAwait(false);
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>Removes revoked, expired and failed share links from the store.</summary>
[HttpPost("Admin/Cleanup")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult> Cleanup(CancellationToken cancellationToken)
{
SetNoStoreHeaders();
if (!User.IsInRole("Administrator"))
{
return Forbid();
}
var removed = await _cleanupService.PurgeFinishedAsync(cancellationToken).ConfigureAwait(false);
return Ok(new { removed });
}
/// <summary>
/// Lists the installed plugins so the config page can offer them as guard
/// exceptions. ShareLinks itself is left out: it is always reachable, since the
/// guest's browser fetches the lockdown script and guest state from it.
/// </summary>
[HttpGet("Admin/Plugins")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public ActionResult<IEnumerable<ShareLinkPluginDto>> Plugins()
{
SetNoStoreHeaders();
if (!User.IsInRole("Administrator"))
{
return Forbid();
}
var allowed = Config.GuestAllowedPluginIds ?? Array.Empty<string>();
var ownId = Plugin.Instance?.Id;
var plugins = _pluginManager.Plugins
.Where(plugin => !ownId.HasValue || plugin.Id != ownId.Value)
.Select(plugin => new ShareLinkPluginDto
{
Id = plugin.Id,
Name = plugin.Name,
AllowedForGuests = allowed.Any(value => Guid.TryParse(value, out var parsed) && parsed == plugin.Id)
})
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
return Ok(plugins);
}
/// <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,
HiddenSelectors = config.GuestHiddenSelectors
});
}
}
return Ok(new ShareLinkGuestStateDto
{
IsGuest = false,
LockdownEnabled = config.GuestModeLockdownEnabled,
HiddenSelectors = config.GuestHiddenSelectors
});
}
/// <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 LinkUnavailablePage(Request);
}
var result = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false);
if (result.AtCapacity)
{
return LinkBusyPage();
}
if (result.Html is null)
{
return LinkUnavailablePage(Request);
}
return Content(result.Html, "text/html; charset=utf-8");
}
private static ContentResult LinkUnavailablePage(HttpRequest request)
{
var pathBase = request.PathBase.Value ?? string.Empty;
var redirectUrl = $"{pathBase}/web/";
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
var redirectUrlHtml = System.Net.WebUtility.HtmlEncode(redirectUrl);
var html = $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="6;url={{redirectUrlHtml}}">
<title>Link unavailable</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; }
main { max-width: 36rem; padding: 2rem; }
.muted { color: #9ca3af; }
a { color: #60a5fa; }
</style>
</head>
<body>
<main>
<div>This share link is no longer valid.</div>
<div class="muted">Ce lien de partage n'est plus valide.</div>
<div class="muted">Taking you to the home page...</div>
<p><a href="{{redirectUrlHtml}}">Open Jellyfin</a></p>
</main>
<script>
setTimeout(function () { window.location.replace({{redirectUrlJson}}); }, 4000);
</script>
</body>
</html>
""";
return new ContentResult
{
StatusCode = StatusCodes.Status404NotFound,
ContentType = "text/html; charset=utf-8",
Content = html
};
}
/// <summary>
/// Served when a multi-use link has as many viewers as it is allowed. The link
/// itself is still good, so this deliberately invites a retry instead of
/// looking like a dead link.
/// </summary>
private static ContentResult LinkBusyPage()
{
var html = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Too many viewers</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; }
main { max-width: 36rem; padding: 2rem; }
.muted { color: #9ca3af; }
a { color: #60a5fa; }
</style>
</head>
<body>
<main>
<div>This link is being watched by as many people as it allows right now.</div>
<div class="muted">Ce lien est deja utilise par autant de personnes qu'il l'autorise.</div>
<p><a href="">Try again</a></p>
</main>
</body>
</html>
""";
return new ContentResult
{
StatusCode = StatusCodes.Status503ServiceUnavailable,
ContentType = "text/html; charset=utf-8",
Content = html
};
}
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,
ShareUrl = record.ShareUrl
};
}
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;
}
/// <summary>
/// Real video titles, home videos, photos and collections are shareable.
/// Anything else - a person, studio, genre, library, playlist, music track or
/// book - would either give the guest nothing to play or pull unrelated items
/// into the guest's tag policy, so the API refuses it even when a client asks
/// for it. A BoxSet shares every item beneath it in one link: ItemTagService
/// tags the whole tree, same as it does for a series' seasons and episodes.
/// </summary>
private static bool IsShareableItem(BaseItem item)
{
return item is Movie or Series or Season or Episode or Video or BoxSet or Photo;
}
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)}";
}
}