commit c29ea7f20cd05bd84e2101c9dc8a2d81ca5806bf Author: Franciskid Date: Mon Jul 6 18:21:22 2026 +0200 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. diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs new file mode 100644 index 0000000..cb782c6 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs @@ -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; + +/// 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)}"; + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..dbc3799 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs @@ -0,0 +1,44 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.ShareLinks.Configuration; + +/// +/// Plugin configuration persisted by Jellyfin. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// Gets or sets a value indicating whether the plugin is enabled. + public bool Enabled { get; set; } = true; + + /// Gets or sets the default share expiry in hours. + public int DefaultExpiryHours { get; set; } = 24; + + /// Gets or sets the maximum allowed share expiry in hours. + public int MaxExpiryHours { get; set; } = 720; + + /// + /// Gets or sets an override for the public base URL used when building + /// absolute share links. Empty means "derive from the incoming request". + /// + public string PublicBaseUrlOverride { get; set; } = string.Empty; + + /// + /// Gets or sets the prefix used when creating guest user names. + /// + public string GuestUsernamePrefix { get; set; } = "share-"; + + /// Gets or sets a value indicating whether shares may transcode. + public bool AllowTranscoding { get; set; } = true; + + /// Gets or sets a value indicating whether shares may remux. + public bool AllowRemuxing { get; set; } = true; + + /// Gets or sets the cleanup interval, in minutes. + public int CleanupIntervalMinutes { get; set; } = 60; + + /// Gets or sets a value indicating whether links default to one use. + public bool OneUseDefault { get; set; } = true; + + /// Gets or sets a value indicating whether guest-mode lockdown is enabled. + public bool GuestModeLockdownEnabled { get; set; } = true; +} diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj new file mode 100644 index 0000000..8c3035b --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + latest + Jellyfin.Plugin.ShareLinks + Jellyfin.Plugin.ShareLinks + 1.0.0.0 + 1.0.0.0 + 1.0.0.0 + true + false + disable + + + + + + + + + + + diff --git a/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs b/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs new file mode 100644 index 0000000..55ee21b --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Lifecycle/StartupCleanupHostedService.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.ShareLinks.Services; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Lifecycle; + +/// +/// Runs one cleanup pass at startup so stale records do not linger forever. +/// +public sealed class StartupCleanupHostedService : BackgroundService +{ + private readonly IShareLinkCleanupService _cleanupService; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + public StartupCleanupHostedService( + IShareLinkCleanupService cleanupService, + ILogger logger) + { + _cleanupService = cleanupService; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await _cleanupService.CleanupAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: startup cleanup failed."); + } + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs new file mode 100644 index 0000000..d02cd84 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkRecord.cs @@ -0,0 +1,70 @@ +using System; + +namespace Jellyfin.Plugin.ShareLinks.Models; + +/// +/// Persistent share-link record. Only the token hash is stored; the raw token +/// never enters durable storage. +/// +public sealed class ShareLinkRecord +{ + /// Gets or sets the share-link id. + public Guid Id { get; set; } = Guid.NewGuid(); + + /// Gets or sets the HMAC hash of the token. + public string TokenHash { get; set; } = string.Empty; + + /// Gets or sets the Jellyfin item id snapshot. + public string ItemId { get; set; } = string.Empty; + + /// Gets or sets the Jellyfin item name snapshot. + public string ItemNameSnapshot { get; set; } = string.Empty; + + /// Gets or sets the library id snapshot. + public string? LibraryId { get; set; } + + /// Gets or sets the user id that created the link. + public Guid? CreatedByUserId { get; set; } + + /// Gets or sets the UTC creation time. + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + /// Gets or sets the UTC redemption time, if any. + public DateTimeOffset? RedeemedAtUtc { get; set; } + + /// Gets or sets the UTC expiry time. + public DateTimeOffset ExpiresAtUtc { get; set; } + + /// Gets or sets the current lifecycle status. + public ShareLinkStatus Status { get; set; } = ShareLinkStatus.Pending; + + /// Gets or sets the guest user id associated with the link. + public Guid? GuestUserId { get; set; } + + /// Gets or sets the guest user name associated with the link. + public string? GuestUserName { get; set; } + + /// Gets or sets the access token id used by the guest session, if available. + public string? AccessTokenId { get; set; } + + /// Gets or sets the device id used by the guest session, if available. + public string? DeviceId { get; set; } + + /// Gets or sets the allowed tag snapshot, if any. + public string? AllowedTag { get; set; } + + /// Gets or sets a value indicating whether the link may be used once only. + public bool OneUse { get; set; } = true; + + /// Gets or sets the encrypted guest password, if one has been generated. + public string? GuestPasswordEncrypted { get; set; } + + /// Gets or sets a value indicating whether metadata was touched during cleanup. + public bool MetadataTouched { get; set; } + + /// Gets or sets the number of cleanup attempts performed on this record. + public int CleanupAttempts { get; set; } + + /// Gets or sets the last cleanup error, if any. + public string? CleanupError { get; set; } +} diff --git a/Jellyfin.Plugin.ShareLinks/Models/ShareLinkStatus.cs b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkStatus.cs new file mode 100644 index 0000000..266f93a --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Models/ShareLinkStatus.cs @@ -0,0 +1,13 @@ +namespace Jellyfin.Plugin.ShareLinks.Models; + +/// Lifecycle state of a share link. +public enum ShareLinkStatus +{ + Pending = 0, + Active = 1, + Redeemed = 2, + Expired = 3, + Revoked = 4, + Failed = 5, + Redeeming = 6 +} diff --git a/Jellyfin.Plugin.ShareLinks/Models/ShareTokenMaterial.cs b/Jellyfin.Plugin.ShareLinks/Models/ShareTokenMaterial.cs new file mode 100644 index 0000000..6cfc173 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Models/ShareTokenMaterial.cs @@ -0,0 +1,13 @@ +namespace Jellyfin.Plugin.ShareLinks.Models; + +/// +/// A freshly generated share token and its persisted hash. +/// +public sealed class ShareTokenMaterial +{ + /// Gets or sets the raw token returned once to the caller. + public string Token { get; set; } = string.Empty; + + /// Gets or sets the HMAC hash stored durably. + public string TokenHash { get; set; } = string.Empty; +} diff --git a/Jellyfin.Plugin.ShareLinks/Plugin.cs b/Jellyfin.Plugin.ShareLinks/Plugin.cs new file mode 100644 index 0000000..c64872c --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Plugin.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; +using Jellyfin.Plugin.ShareLinks.Configuration; + +namespace Jellyfin.Plugin.ShareLinks; + +/// +/// ShareLinks plugin. Creates expiring guest-share links for Jellyfin items +/// without persisting raw tokens. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// Initializes a new instance of the class. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// Gets the current plugin instance. + public static Plugin? Instance { get; private set; } + + /// + public override string Name => "ShareLinks"; + + /// + public override string Description => + "Secure expiring share links for Jellyfin items with guest-user lockdown."; + + /// + public override Guid Id => Guid.Parse("68540b76-ee74-436d-85ff-2abc884bbea6"); + + /// + public IEnumerable GetPages() => new[] + { + new PluginPageInfo + { + Name = "ShareLinks", + EmbeddedResourcePath = GetType().Namespace + ".Web.configPage.html" + } + }; +} diff --git a/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs b/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs new file mode 100644 index 0000000..851d6d0 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs @@ -0,0 +1,33 @@ +using Jellyfin.Plugin.ShareLinks.Lifecycle; +using Jellyfin.Plugin.ShareLinks.Services; +using Jellyfin.Plugin.ShareLinks.Storage; +using Jellyfin.Plugin.ShareLinks.Web; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.ShareLinks; + +/// +/// Registers the foundational ShareLinks services used by later API and web +/// workers. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + _ = applicationHost; + + serviceCollection.AddHostedService(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(provider => provider.GetRequiredService()); + serviceCollection.AddHostedService(); + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Services/IShareLinkCleanupService.cs b/Jellyfin.Plugin.ShareLinks/Services/IShareLinkCleanupService.cs new file mode 100644 index 0000000..2ac457c --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Services/IShareLinkCleanupService.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.ShareLinks.Services; + +/// +/// Cleanup seam for later workers. The initial implementation is a no-op. +/// +public interface IShareLinkCleanupService +{ + /// Runs one cleanup pass. + Task CleanupAsync(CancellationToken cancellationToken); +} diff --git a/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs b/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs new file mode 100644 index 0000000..6d92b96 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Services/ItemTagService.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Services; + +/// Applies and removes temporary tags on shared items. +public sealed class ItemTagService +{ + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + public ItemTagService(ILibraryManager libraryManager, ILogger logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + /// Ensures the supplied tag is present on the item and persisted. + public async Task EnsureTagAsync(BaseItem item, string tag, CancellationToken cancellationToken) + { + if (item is null) + { + throw new ArgumentNullException(nameof(item)); + } + + if (string.IsNullOrWhiteSpace(tag)) + { + throw new ArgumentException("Tag cannot be empty.", nameof(tag)); + } + + var tags = item.Tags?.ToList() ?? new List(); + if (tags.Any(existing => string.Equals(existing, tag, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + tags.Add(tag); + item.Tags = tags.ToArray(); + await PersistAsync(item, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("ShareLinks: applied temporary tag {Tag} to item {ItemId}.", tag, item.Id); + return true; + } + + /// Removes the supplied tag from the item and persists the change. + public async Task RemoveTagAsync(BaseItem item, string tag, CancellationToken cancellationToken) + { + if (item is null) + { + throw new ArgumentNullException(nameof(item)); + } + + if (string.IsNullOrWhiteSpace(tag)) + { + throw new ArgumentException("Tag cannot be empty.", nameof(tag)); + } + + var tags = item.Tags?.ToList() ?? new List(); + var removed = tags.RemoveAll(existing => string.Equals(existing, tag, StringComparison.OrdinalIgnoreCase)) > 0; + if (!removed) + { + return false; + } + + item.Tags = tags.ToArray(); + await PersistAsync(item, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("ShareLinks: removed temporary tag {Tag} from item {ItemId}.", tag, item.Id); + return true; + } + + private async Task PersistAsync(BaseItem item, CancellationToken cancellationToken) + { + var method = _libraryManager.GetType() + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .FirstOrDefault(candidate => + { + if (!string.Equals(candidate.Name, "UpdateItemAsync", StringComparison.Ordinal)) + { + return false; + } + + var parameters = candidate.GetParameters(); + return parameters.Length == 4 + && typeof(BaseItem).IsAssignableFrom(parameters[0].ParameterType) + && typeof(BaseItem).IsAssignableFrom(parameters[1].ParameterType) + && parameters[3].ParameterType == typeof(CancellationToken); + }); + + if (method is null) + { + throw new MissingMethodException(_libraryManager.GetType().FullName, "UpdateItemAsync"); + } + + var parametersInfo = method.GetParameters(); + var updateReason = parametersInfo[2].ParameterType.IsEnum + ? Enum.ToObject(parametersInfo[2].ParameterType, 0) + : 0; + + var parent = item.DisplayParent ?? item; + var task = method.Invoke(_libraryManager, new object?[] + { + item, + parent, + updateReason, + cancellationToken + }) as Task; + + if (task is null) + { + throw new InvalidOperationException("UpdateItemAsync did not return a task."); + } + + await task.ConfigureAwait(false); + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs new file mode 100644 index 0000000..45b50d1 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs @@ -0,0 +1,514 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.ShareLinks.Configuration; +using Jellyfin.Plugin.ShareLinks.Models; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Users; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Services; + +/// Creates and tears down temporary Jellyfin guest users. +public sealed class JellyfinGuestUserService +{ + private readonly IUserManager _userManager; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + public JellyfinGuestUserService(IUserManager userManager, ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + /// Builds the temporary guest username for a share record. + public static string BuildGuestUsername(ShareLinkRecord record) + { + var prefix = Plugin.Instance?.Configuration.GuestUsernamePrefix ?? "share-"; + return $"{prefix}{record.Id:N}"; + } + + /// Generates a strong random password suitable for a temporary guest user. + public static string GeneratePassword() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64UrlEncode(bytes); + } + + /// Ensures the temporary guest user exists and has the correct policy and password. + public async Task EnsureGuestUserAsync(ShareLinkRecord record, string password, CancellationToken cancellationToken) + { + if (record is null) + { + throw new ArgumentNullException(nameof(record)); + } + + if (string.IsNullOrWhiteSpace(password)) + { + throw new ArgumentException("Password cannot be empty.", nameof(password)); + } + + var username = record.GuestUserName; + if (string.IsNullOrWhiteSpace(username)) + { + username = BuildGuestUsername(record); + record.GuestUserName = username; + } + + object? user = _userManager.GetUserByName(username); + if (user is null) + { + user = await InvokeUserManagerAsync( + "create user", + cancellationToken, + new InvocationCandidate("CreateUserAsync", new object?[] { username }), + new InvocationCandidate("CreateUser", new object?[] { username })) + .ConfigureAwait(false) ?? _userManager.GetUserByName(username); + + if (user is null) + { + throw new InvalidOperationException($"Unable to create temporary guest user '{username}'."); + } + } + + var existingUserId = GetUserId(user); + if (existingUserId != Guid.Empty) + { + user = _userManager.GetUserById(existingUserId) ?? user; + } + + // The password must be changed before the policy update: UpdatePolicyAsync bumps the + // user's EF concurrency token server-side, and ChangePassword with a stale instance + // throws DbUpdateConcurrencyException. + await ChangePasswordAsync(user, password, cancellationToken).ConfigureAwait(false); + await ApplyPolicyAsync(user, record, disabled: false, cancellationToken).ConfigureAwait(false); + + var userId = GetUserId(user); + if (userId == Guid.Empty) + { + throw new InvalidOperationException("ShareLinks: created guest user did not expose a valid Id."); + } + + user = _userManager.GetUserById(userId) ?? user; + _logger.LogInformation("ShareLinks: ensured guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); + return user; + } + + /// Disables a temporary guest user before deletion. + public async Task DisableGuestUserAsync(ShareLinkRecord record, CancellationToken cancellationToken) + { + var user = FindRecordUser(record); + if (user is null) + { + return; + } + + try + { + await ApplyPolicyAsync(user, record, disabled: true, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: failed to disable guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); + } + } + + /// Deletes a temporary guest user if it exists. + public async Task DeleteGuestUserAsync(ShareLinkRecord record, CancellationToken cancellationToken) + { + var user = FindRecordUser(record); + if (user is null) + { + return; + } + + try + { + await DeleteUserAsync(user, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: failed to delete guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); + throw; + } + } + + private object? FindRecordUser(ShareLinkRecord record) + { + if (record.GuestUserId.HasValue) + { + var user = _userManager.GetUserById(record.GuestUserId.Value); + if (user is not null) + { + return user; + } + } + + if (!string.IsNullOrWhiteSpace(record.GuestUserName)) + { + return _userManager.GetUserByName(record.GuestUserName); + } + + return null; + } + + private async Task ApplyPolicyAsync(object user, ShareLinkRecord record, bool disabled, CancellationToken cancellationToken) + { + var config = Plugin.Instance!.Configuration; + var policy = new UserPolicy(); + + SetPolicyValue(policy, "AuthenticationProviderId", GetUserValue(user, "AuthenticationProviderId")); + SetPolicyValue(policy, "PasswordResetProviderId", GetUserValue(user, "PasswordResetProviderId")); + SetPolicyValue(policy, "AllowedTags", string.IsNullOrWhiteSpace(record.AllowedTag) ? Array.Empty() : new[] { record.AllowedTag! }); + SetPolicyValue(policy, "BlockedTags", Array.Empty()); + SetPolicyValue(policy, "IsAdministrator", false); + SetPolicyValue(policy, "IsHidden", true); + SetPolicyValue(policy, "IsDisabled", disabled); + SetPolicyValue(policy, "EnableCollectionManagement", false); + SetPolicyValue(policy, "EnableSubtitleManagement", false); + SetPolicyValue(policy, "EnableLyricManagement", false); + SetPolicyValue(policy, "EnableUserPreferenceAccess", false); + SetPolicyValue(policy, "EnableSharedDeviceControl", false); + SetPolicyValue(policy, "EnableRemoteAccess", true); + SetPolicyValue(policy, "EnableRemoteControlOfOtherUsers", false); + SetPolicyValue(policy, "EnableLiveTvManagement", false); + SetPolicyValue(policy, "EnableLiveTvAccess", false); + SetPolicyValue(policy, "EnableMediaPlayback", true); + SetPolicyValue(policy, "EnableAudioPlaybackTranscoding", config.AllowTranscoding); + SetPolicyValue(policy, "EnableVideoPlaybackTranscoding", config.AllowTranscoding); + SetPolicyValue(policy, "EnablePlaybackRemuxing", config.AllowRemuxing); + SetPolicyValue(policy, "ForceRemoteSourceTranscoding", false); + SetPolicyValue(policy, "EnableContentDeletion", false); + SetPolicyValue(policy, "EnableContentDeletionFromFolders", Array.Empty()); + SetPolicyValue(policy, "EnableContentDownloading", false); + SetPolicyValue(policy, "EnableSyncTranscoding", false); + SetPolicyValue(policy, "EnableMediaConversion", false); + SetPolicyValue(policy, "EnableAllChannels", false); + SetPolicyValue(policy, "EnabledChannels", Array.Empty()); + SetPolicyValue(policy, "EnableAllDevices", true); + SetPolicyValue(policy, "EnabledDevices", Array.Empty()); + SetPolicyValue(policy, "EnableAllFolders", true); + SetPolicyValue(policy, "EnabledFolders", Array.Empty()); + SetPolicyValue(policy, "EnablePublicSharing", false); + SetPolicyValue(policy, "LoginAttemptsBeforeLockout", -1); + SetPolicyValue(policy, "MaxActiveSessions", 1); + SetPolicyValue(policy, "BlockUnratedItems", Array.Empty()); + + await InvokeUserManagerAsync( + "update policy", + cancellationToken, + new InvocationCandidate("UpdatePolicyAsync", new object?[] { GetUserId(user), policy }), + new InvocationCandidate("UpdatePolicyAsync", new object?[] { user, policy }), + new InvocationCandidate("UpdatePolicy", new object?[] { GetUserId(user), policy }), + new InvocationCandidate("UpdatePolicy", new object?[] { user, policy })) + .ConfigureAwait(false); + } + + private async Task ChangePasswordAsync(object user, string password, CancellationToken cancellationToken) + { + await InvokeUserManagerAsync( + "change password", + cancellationToken, + new InvocationCandidate("ChangePasswordAsync", new object?[] { user, password }), + new InvocationCandidate("ChangePasswordAsync", new object?[] { GetUserId(user), password }), + new InvocationCandidate("ChangePasswordAsync", new object?[] { user, string.Empty, password }), + new InvocationCandidate("ChangePasswordAsync", new object?[] { GetUserId(user), string.Empty, password }), + new InvocationCandidate("ChangePassword", new object?[] { user, password }), + new InvocationCandidate("ChangePassword", new object?[] { GetUserId(user), password }), + new InvocationCandidate("ChangePassword", new object?[] { user, string.Empty, password }), + new InvocationCandidate("ChangePassword", new object?[] { GetUserId(user), string.Empty, password })) + .ConfigureAwait(false); + } + + private async Task DeleteUserAsync(object user, CancellationToken cancellationToken) + { + await InvokeUserManagerAsync( + "delete user", + cancellationToken, + new InvocationCandidate("DeleteUserAsync", new object?[] { GetUserId(user) }), + new InvocationCandidate("DeleteUserAsync", new object?[] { user }), + new InvocationCandidate("DeleteUser", new object?[] { GetUserId(user) }), + new InvocationCandidate("DeleteUser", new object?[] { user })) + .ConfigureAwait(false); + } + + private async Task InvokeUserManagerAsync( + string operationName, + CancellationToken cancellationToken, + params InvocationCandidate[] candidates) + { + var managerType = _userManager.GetType(); + var triedVariants = new List(); + + foreach (var candidate in candidates) + { + var methods = managerType.GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(method => string.Equals(method.Name, candidate.MethodName, StringComparison.Ordinal)); + + var matchedMethod = false; + foreach (var method in methods) + { + if (!TryBindArguments(method, candidate.Arguments, cancellationToken, out var invocationArguments)) + { + continue; + } + + matchedMethod = true; + var invocation = method.Invoke(_userManager, invocationArguments); + if (invocation is Task task) + { + await task.WaitAsync(cancellationToken).ConfigureAwait(false); + + var resultProperty = invocation.GetType().GetProperty("Result", BindingFlags.Instance | BindingFlags.Public); + if (resultProperty is null) + { + return default; + } + + var result = resultProperty.GetValue(invocation); + if (result is null) + { + return default; + } + + if (result is T typedResult) + { + return typedResult; + } + + throw new InvalidOperationException( + $"ShareLinks: {managerType.FullName}.{candidate.MethodName} returned incompatible result type {result.GetType().FullName} for {operationName}."); + } + + if (invocation is T directResult) + { + return directResult; + } + + if (invocation is null) + { + return default; + } + + throw new InvalidOperationException( + $"ShareLinks: {managerType.FullName}.{candidate.MethodName} returned incompatible result type {invocation.GetType().FullName} for {operationName}."); + } + + if (!matchedMethod) + { + triedVariants.Add($"{candidate.MethodName}({DescribeArguments(candidate.Arguments)})"); + } + } + + _logger.LogWarning( + "ShareLinks: {UserManagerType} does not expose a compatible {Operation} variant. Tried {Variants}.", + managerType.FullName, + operationName, + string.Join("; ", triedVariants)); + throw new MissingMethodException(managerType.FullName, operationName); + } + + private static string DescribeArguments(object?[] arguments) + { + return string.Join(", ", arguments.Select(argument => argument?.GetType().Name ?? "null")); + } + + private static bool TryBindArguments(MethodInfo method, object?[] suppliedArguments, CancellationToken cancellationToken, out object?[] invocationArguments) + { + var parameters = method.GetParameters(); + if (suppliedArguments.Length > parameters.Length) + { + invocationArguments = Array.Empty(); + return false; + } + + invocationArguments = new object?[parameters.Length]; + for (var index = 0; index < suppliedArguments.Length; index++) + { + if (!TryConvertValue(parameters[index].ParameterType, suppliedArguments[index], out var convertedArgument)) + { + invocationArguments = Array.Empty(); + return false; + } + + invocationArguments[index] = convertedArgument; + } + + for (var index = suppliedArguments.Length; index < parameters.Length; index++) + { + var parameter = parameters[index]; + if (parameter.ParameterType == typeof(CancellationToken)) + { + invocationArguments[index] = cancellationToken; + continue; + } + + if (parameter.IsOptional) + { + invocationArguments[index] = GetOptionalParameterValue(parameter); + continue; + } + + invocationArguments = Array.Empty(); + return false; + } + + return true; + } + + private static object? GetOptionalParameterValue(ParameterInfo parameter) + { + var defaultValue = parameter.DefaultValue; + if (defaultValue is not null && defaultValue != DBNull.Value && defaultValue != Type.Missing) + { + return defaultValue; + } + + return parameter.ParameterType.IsValueType + ? Activator.CreateInstance(parameter.ParameterType) + : null; + } + + private static void SetPolicyValue(UserPolicy policy, string memberName, object? value) + { + var policyType = policy.GetType(); + + var property = policyType.GetProperty(memberName, BindingFlags.Instance | BindingFlags.Public); + if (property is not null && property.CanWrite && TryConvertValue(property.PropertyType, value, out var convertedPropertyValue)) + { + property.SetValue(policy, convertedPropertyValue); + return; + } + + var field = policyType.GetField(memberName, BindingFlags.Instance | BindingFlags.Public); + if (field is not null && TryConvertValue(field.FieldType, value, out var convertedFieldValue)) + { + field.SetValue(policy, convertedFieldValue); + } + } + + private static Guid GetUserId(object user) + { + var value = GetUserValue(user, "Id"); + if (value is Guid guid) + { + return guid; + } + + if (value is string text && Guid.TryParse(text, out var parsedGuid)) + { + return parsedGuid; + } + + return Guid.Empty; + } + + private static string GetUserName(object user) + { + var value = GetUserValue(user, "Username"); + if (value is string username && !string.IsNullOrWhiteSpace(username)) + { + return username; + } + + value = GetUserValue(user, "Name"); + return value as string ?? string.Empty; + } + + private static object? GetUserValue(object user, string propertyName) + { + var property = user.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public); + return property?.GetValue(user); + } + + private static bool TryConvertValue(Type targetType, object? value, out object? converted) + { + if (targetType.IsByRef) + { + targetType = targetType.GetElementType() ?? targetType; + } + + var nonNullableType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (value is null) + { + converted = null; + return !nonNullableType.IsValueType || Nullable.GetUnderlyingType(targetType) is not null; + } + + if (nonNullableType.IsInstanceOfType(value) || targetType.IsAssignableFrom(value.GetType())) + { + converted = value; + return true; + } + + if (nonNullableType.IsEnum) + { + if (value is string text) + { + converted = Enum.Parse(nonNullableType, text, ignoreCase: true); + return true; + } + + if (IsNumeric(value)) + { + converted = Enum.ToObject(nonNullableType, value); + return true; + } + } + + if (nonNullableType == typeof(Guid) && value is string guidText && Guid.TryParse(guidText, out var guid)) + { + converted = guid; + return true; + } + + if (value is IConvertible) + { + try + { + converted = Convert.ChangeType(value, nonNullableType, CultureInfo.InvariantCulture); + return true; + } + catch + { + // The caller will ignore the missing or incompatible policy member. + } + } + + converted = null; + return false; + } + + private static bool IsNumeric(object value) + { + return value is byte + or sbyte + or short + or ushort + or int + or uint + or long + or ulong + or float + or double + or decimal; + } + + private sealed record InvocationCandidate(string MethodName, object?[] Arguments); + + private static string Base64UrlEncode(ReadOnlySpan bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Services/NoOpShareLinkCleanupService.cs b/Jellyfin.Plugin.ShareLinks/Services/NoOpShareLinkCleanupService.cs new file mode 100644 index 0000000..c5005df --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Services/NoOpShareLinkCleanupService.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.ShareLinks.Services; + +/// +/// Temporary cleanup implementation used until the real cleanup pipeline lands. +/// +public sealed class NoOpShareLinkCleanupService : IShareLinkCleanupService +{ + /// + public Task CleanupAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + return Task.CompletedTask; + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCleanupService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCleanupService.cs new file mode 100644 index 0000000..9abc3d6 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCleanupService.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.ShareLinks.Models; +using Jellyfin.Plugin.ShareLinks.Storage; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Services; + +/// Cleanly expires links and tears down temporary guest state. +public sealed class ShareLinkCleanupService : IShareLinkCleanupService +{ + private readonly ShareLinkStore _store; + private readonly ILibraryManager _libraryManager; + private readonly ItemTagService _itemTagService; + private readonly JellyfinGuestUserService _guestUserService; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + public ShareLinkCleanupService( + ShareLinkStore store, + ILibraryManager libraryManager, + ItemTagService itemTagService, + JellyfinGuestUserService guestUserService, + ILogger logger) + { + _store = store; + _libraryManager = libraryManager; + _itemTagService = itemTagService; + _guestUserService = guestUserService; + _logger = logger; + } + + /// + public async Task CleanupAsync(CancellationToken cancellationToken) + { + var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false); + foreach (var record in records) + { + await CleanupRecordInternalAsync(record, records, false, cancellationToken).ConfigureAwait(false); + } + } + + /// Revokes a specific share link and immediately runs teardown. + public async Task RevokeAsync(Guid id, CancellationToken cancellationToken) + { + var record = await _store.GetByIdAsync(id, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return null; + } + + record.Status = ShareLinkStatus.Revoked; + record.CleanupError = null; + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + + var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false); + return await CleanupRecordInternalAsync(record, records, true, cancellationToken).ConfigureAwait(false); + } + + /// Runs cleanup for one record by id. + public async Task CleanupRecordAsync(Guid id, bool force, CancellationToken cancellationToken) + { + var record = await _store.GetByIdAsync(id, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return; + } + + var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false); + await CleanupRecordInternalAsync(record, records, force, cancellationToken).ConfigureAwait(false); + } + + private async Task CleanupRecordInternalAsync( + ShareLinkRecord record, + IReadOnlyList allRecords, + bool force, + CancellationToken cancellationToken) + { + record.CleanupAttempts += 1; + var now = DateTimeOffset.UtcNow; + var shouldExpire = record.ExpiresAtUtc <= now && record.Status is not ShareLinkStatus.Expired and not ShareLinkStatus.Revoked; + if (shouldExpire) + { + record.Status = ShareLinkStatus.Expired; + } + + var shouldTeardown = force + || record.Status is ShareLinkStatus.Expired + || record.Status is ShareLinkStatus.Revoked + || record.Status is ShareLinkStatus.Failed; + + if (!shouldTeardown) + { + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + return record; + } + + var errors = new List(); + try + { + await _guestUserService.DisableGuestUserAsync(record, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + errors.Add($"disable:{ex.Message}"); + _logger.LogWarning(ex, "ShareLinks: failed to disable guest user for record {RecordId}.", record.Id); + } + + try + { + await _guestUserService.DeleteGuestUserAsync(record, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + errors.Add($"delete:{ex.Message}"); + _logger.LogWarning(ex, "ShareLinks: failed to delete guest user for record {RecordId}.", record.Id); + } + + if (!string.IsNullOrWhiteSpace(record.AllowedTag) && !IsTagStillInUse(record, allRecords, now)) + { + var item = TryGetItem(record.ItemId); + if (item is not null) + { + try + { + var removed = await _itemTagService.RemoveTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); + record.MetadataTouched |= removed; + } + catch (Exception ex) + { + errors.Add($"tag:{ex.Message}"); + _logger.LogWarning(ex, "ShareLinks: failed to remove tag {Tag} from record {RecordId}.", record.AllowedTag, record.Id); + } + } + } + + record.CleanupError = errors.Count == 0 ? null : string.Join(" | ", errors); + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + return record; + } + + private BaseItem? TryGetItem(string itemId) + { + if (!Guid.TryParse(itemId, out var id)) + { + return null; + } + + return _libraryManager.GetItemById(id); + } + + private static bool IsTagStillInUse(ShareLinkRecord record, IReadOnlyList allRecords, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(record.AllowedTag)) + { + return false; + } + + return allRecords.Any(other => + other.Id != record.Id + && string.Equals(other.AllowedTag, record.AllowedTag, StringComparison.OrdinalIgnoreCase) + && other.ExpiresAtUtc > now + && other.Status is ShareLinkStatus.Pending + or ShareLinkStatus.Active + or ShareLinkStatus.Redeeming + or ShareLinkStatus.Redeemed); + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCreationService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCreationService.cs new file mode 100644 index 0000000..be934a1 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkCreationService.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.ShareLinks.Models; +using Jellyfin.Plugin.ShareLinks.Storage; +using MediaBrowser.Controller.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Services; + +/// Creates durable ShareLinks records and applies the temporary tag. +public sealed class ShareLinkCreationService +{ + private readonly ShareLinkStore _store; + private readonly ShareTokenService _tokenService; + private readonly ItemTagService _itemTagService; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + public ShareLinkCreationService( + ShareLinkStore store, + ShareTokenService tokenService, + ItemTagService itemTagService, + ILogger logger) + { + _store = store; + _tokenService = tokenService; + _itemTagService = itemTagService; + _logger = logger; + } + + /// Creates a new share-link record and returns the raw token once. + public async Task<(ShareLinkRecord Record, string RawToken)> CreateAsync( + BaseItem item, + Guid createdByUserId, + int expiryHours, + bool oneUse, + CancellationToken cancellationToken) + { + if (item is null) + { + throw new ArgumentNullException(nameof(item)); + } + + var token = await _tokenService.GenerateAsync(cancellationToken).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + var record = new ShareLinkRecord + { + Id = Guid.NewGuid(), + TokenHash = token.TokenHash, + ItemId = item.Id.ToString("D"), + ItemNameSnapshot = item.Name ?? string.Empty, + CreatedByUserId = createdByUserId == Guid.Empty ? null : createdByUserId, + CreatedAtUtc = now, + ExpiresAtUtc = now.AddHours(expiryHours), + Status = ShareLinkStatus.Pending, + OneUse = oneUse, + AllowedTag = $"sharelinks-{Guid.NewGuid():N}", + CleanupAttempts = 0 + }; + + await _store.UpsertAsync(record, cancellationToken).ConfigureAwait(false); + + try + { + if (!string.IsNullOrWhiteSpace(record.AllowedTag)) + { + record.MetadataTouched = await _itemTagService.EnsureTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); + } + + record.Status = ShareLinkStatus.Active; + record.CleanupError = null; + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + return (record, token.Token); + } + catch (Exception ex) + { + record.Status = ShareLinkStatus.Failed; + record.CleanupError = ex.Message; + record.MetadataTouched = true; + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + _logger.LogWarning(ex, "ShareLinks: failed to finish creation for record {RecordId}.", record.Id); + throw; + } + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs new file mode 100644 index 0000000..408aece --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareLinkRedemptionService.cs @@ -0,0 +1,269 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.ShareLinks.Models; +using Jellyfin.Plugin.ShareLinks.Storage; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Services; + +/// 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 ILogger _logger; + + /// Initializes a new instance of the class. + public ShareLinkRedemptionService( + ILibraryManager libraryManager, + ShareLinkStore store, + ShareTokenService tokenService, + ItemTagService itemTagService, + JellyfinGuestUserService guestUserService, + ShareLinkCleanupService cleanupService, + ILogger logger) + { + _libraryManager = libraryManager; + _store = store; + _tokenService = tokenService; + _itemTagService = itemTagService; + _guestUserService = guestUserService; + _cleanupService = cleanupService; + _logger = logger; + } + + /// Redeems a token and returns the bootstrap HTML, or null if the token is unusable. + public async Task RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) + { + var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false); + var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return null; + } + + var now = DateTimeOffset.UtcNow; + if (record.ExpiresAtUtc <= now) + { + await HandleTerminalRecordAsync(record, ShareLinkStatus.Expired, "Share link has expired.", cancellationToken).ConfigureAwait(false); + return null; + } + + if (record.Status == ShareLinkStatus.Revoked || record.Status == ShareLinkStatus.Failed) + { + return null; + } + + if (!Guid.TryParse(record.ItemId, out var itemId)) + { + await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false); + return null; + } + + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false); + return null; + } + + if (!string.IsNullOrWhiteSpace(record.AllowedTag)) + { + await _itemTagService.EnsureTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false); + record.MetadataTouched = true; + } + + if (record.OneUse && record.Status == ShareLinkStatus.Redeemed) + { + return null; + } + + if (string.IsNullOrWhiteSpace(record.DeviceId)) + { + record.DeviceId = Guid.NewGuid().ToString("N"); + } + + record.Status = ShareLinkStatus.Redeeming; + record.CleanupError = null; + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + + var password = await GetOrCreatePasswordAsync(record, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(record.GuestUserName)) + { + record.GuestUserName = JellyfinGuestUserService.BuildGuestUsername(record); + } + + try + { + var user = await _guestUserService.EnsureGuestUserAsync(record, password, cancellationToken).ConfigureAwait(false); + record.GuestUserId = user.Id; + record.GuestUserName = user.Username; + record.RedeemedAtUtc ??= now; + record.Status = ShareLinkStatus.Redeemed; + record.CleanupError = null; + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + } + 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 null; + } + + return BuildBootstrapHtml(request, record, password, itemId); + } + + private async Task GetOrCreatePasswordAsync(ShareLinkRecord record, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(record.GuestPasswordEncrypted)) + { + try + { + return await _tokenService.UnprotectStringAsync(record.GuestPasswordEncrypted, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: stored guest password could not be decrypted for record {RecordId}; generating a replacement.", record.Id); + } + } + + var password = JellyfinGuestUserService.GeneratePassword(); + record.GuestPasswordEncrypted = await _tokenService.ProtectStringAsync(password, cancellationToken).ConfigureAwait(false); + record.Status = ShareLinkStatus.Redeeming; + record.CleanupError = null; + await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false); + return password; + } + + 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, ShareLinkRecord record, string password, Guid itemId) + { + var pathBase = request.PathBase.Value ?? string.Empty; + var authUrl = $"{pathBase}/Users/AuthenticateByName"; + var redirectUrl = $"{pathBase}/web/index.html#!/details?id={Uri.EscapeDataString(itemId.ToString("D"))}"; + var username = record.GuestUserName ?? JellyfinGuestUserService.BuildGuestUsername(record); + var deviceId = record.DeviceId ?? string.Empty; + + var authJson = JsonSerializer.Serialize(new + { + Username = username, + Pw = password + }); + + var authUrlJson = JsonSerializer.Serialize(authUrl); + var redirectUrlJson = JsonSerializer.Serialize(redirectUrl); + var usernameJson = JsonSerializer.Serialize(username); + var deviceIdJson = JsonSerializer.Serialize(deviceId); + + return $$""" + + + + + + Signing in... + + + +
+
Signing you in...
+
Preparing temporary access.
+
+ + + +"""; + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Services/ShareTokenService.cs b/Jellyfin.Plugin.ShareLinks/Services/ShareTokenService.cs new file mode 100644 index 0000000..c87084d --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Services/ShareTokenService.cs @@ -0,0 +1,214 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.ShareLinks.Models; +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Services; + +/// +/// Generates raw share tokens and their persisted HMAC hashes. +/// +public sealed class ShareTokenService +{ + private readonly string _secretPath; + private readonly ILogger _logger; + private readonly SemaphoreSlim _secretGate = new(1, 1); + private byte[]? _secretKey; + + /// Initializes a new instance of the class. + public ShareTokenService(IApplicationPaths applicationPaths, ILogger logger) + { + _secretPath = Path.Combine(applicationPaths.DataPath, "sharelinks", "token-secret.key"); + _logger = logger; + } + + /// Creates a new 256-bit token and its HMAC hash. + public async Task GenerateAsync(CancellationToken cancellationToken = default) + { + var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false); + var tokenBytes = new byte[32]; + RandomNumberGenerator.Fill(tokenBytes); + + var token = Base64UrlEncode(tokenBytes); + var hash = ComputeHash(secret, tokenBytes); + + return new ShareTokenMaterial + { + Token = token, + TokenHash = hash + }; + } + + /// Computes the stored hash for a presented token. + public async Task HashTokenAsync(string token, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token)) + { + throw new ArgumentException("Token cannot be empty.", nameof(token)); + } + + var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false); + var tokenBytes = Base64UrlDecode(token); + return ComputeHash(secret, tokenBytes); + } + + /// Validates a token against an expected hash. + public async Task VerifyTokenAsync(string token, string expectedHash, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(expectedHash)) + { + return false; + } + + try + { + var actualHash = await HashTokenAsync(token, cancellationToken).ConfigureAwait(false); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(actualHash), + Encoding.UTF8.GetBytes(expectedHash)); + } + catch (ArgumentException) + { + return false; + } + catch (FormatException) + { + return false; + } + } + + /// Encrypts sensitive text using the shared plugin secret. + public async Task ProtectStringAsync(string value, CancellationToken cancellationToken = default) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false); + var plaintext = Encoding.UTF8.GetBytes(value); + var nonce = new byte[12]; + RandomNumberGenerator.Fill(nonce); + var cipher = new byte[plaintext.Length]; + var tag = new byte[16]; + + using (var aes = new AesGcm(secret, 16)) + { + aes.Encrypt(nonce, plaintext, cipher, tag); + } + + var payload = new byte[nonce.Length + cipher.Length + tag.Length]; + Buffer.BlockCopy(nonce, 0, payload, 0, nonce.Length); + Buffer.BlockCopy(cipher, 0, payload, nonce.Length, cipher.Length); + Buffer.BlockCopy(tag, 0, payload, nonce.Length + cipher.Length, tag.Length); + return Base64UrlEncode(payload); + } + + /// Decrypts a sensitive string protected by . + public async Task UnprotectStringAsync(string protectedValue, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(protectedValue)) + { + throw new ArgumentException("Protected value cannot be empty.", nameof(protectedValue)); + } + + var payload = Base64UrlDecode(protectedValue); + if (payload.Length < 12 + 16) + { + throw new CryptographicException("Protected payload is invalid."); + } + + var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false); + var nonce = payload[..12]; + var tag = payload[^16..]; + var cipher = payload[12..^16]; + var plaintext = new byte[cipher.Length]; + + using (var aes = new AesGcm(secret, 16)) + { + aes.Decrypt(nonce, cipher, tag, plaintext); + } + + return Encoding.UTF8.GetString(plaintext); + } + + private async Task GetSecretAsync(CancellationToken cancellationToken) + { + if (_secretKey is not null) + { + return _secretKey; + } + + await _secretGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_secretKey is not null) + { + return _secretKey; + } + + if (File.Exists(_secretPath)) + { + try + { + var secretText = await File.ReadAllTextAsync(_secretPath, cancellationToken).ConfigureAwait(false); + _secretKey = Base64UrlDecode(secretText.Trim()); + if (_secretKey.Length >= 16) + { + return _secretKey; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: could not load the token secret; a new one will be generated."); + } + } + + var generated = new byte[32]; + RandomNumberGenerator.Fill(generated); + Directory.CreateDirectory(Path.GetDirectoryName(_secretPath)!); + await File.WriteAllTextAsync(_secretPath, Base64UrlEncode(generated), cancellationToken).ConfigureAwait(false); + _secretKey = generated; + return _secretKey; + } + finally + { + _secretGate.Release(); + } + } + + private static string ComputeHash(byte[] secret, ReadOnlySpan tokenBytes) + { + using var hmac = new HMACSHA256(secret); + return Base64UrlEncode(hmac.ComputeHash(tokenBytes.ToArray())); + } + + private static string Base64UrlEncode(ReadOnlySpan bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: + padded += "=="; + break; + case 3: + padded += "="; + break; + } + + return Convert.FromBase64String(padded); + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Storage/ShareLinkStore.cs b/Jellyfin.Plugin.ShareLinks/Storage/ShareLinkStore.cs new file mode 100644 index 0000000..499c75d --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Storage/ShareLinkStore.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.ShareLinks.Models; +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Storage; + +/// +/// JSON-backed persistent store for share-link records. +/// +public sealed class ShareLinkStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = false + }; + + private readonly string _path; + private readonly string _directory; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + + /// Initializes a new instance of the class. + public ShareLinkStore(IApplicationPaths applicationPaths, ILogger logger) + { + _directory = Path.Combine(applicationPaths.DataPath, "sharelinks"); + _path = Path.Combine(_directory, "sharelinks.json"); + _logger = logger; + } + + /// Lists all persisted share links. + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await LoadUnlockedAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// Gets a share link by token hash. + public async Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(tokenHash)) + { + return null; + } + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var records = await LoadUnlockedAsync(cancellationToken).ConfigureAwait(false); + return records.FirstOrDefault(record => + string.Equals(record.TokenHash, tokenHash, StringComparison.Ordinal)); + } + finally + { + _gate.Release(); + } + } + + /// Gets a share link by id. + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var records = await LoadUnlockedAsync(cancellationToken).ConfigureAwait(false); + return records.FirstOrDefault(record => record.Id == id); + } + finally + { + _gate.Release(); + } + } + + /// Inserts or replaces a share-link record. + public async Task UpsertAsync(ShareLinkRecord record, CancellationToken cancellationToken = default) + { + if (record is null) + { + throw new ArgumentNullException(nameof(record)); + } + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var records = await LoadUnlockedAsync(cancellationToken).ConfigureAwait(false); + if (record.Id == Guid.Empty) + { + record.Id = Guid.NewGuid(); + } + + var index = records.FindIndex(existing => existing.Id == record.Id); + if (index >= 0) + { + records[index] = record; + } + else + { + records.Add(record); + } + + await SortAndSaveUnlockedAsync(records, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// Updates an existing share-link record. + public async Task UpdateAsync(ShareLinkRecord record, CancellationToken cancellationToken = default) + { + await UpsertAsync(record, cancellationToken).ConfigureAwait(false); + } + + /// Deletes a share-link record by id. + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var records = await LoadUnlockedAsync(cancellationToken).ConfigureAwait(false); + records.RemoveAll(record => record.Id == id); + await SortAndSaveUnlockedAsync(records, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + private async Task> LoadUnlockedAsync(CancellationToken cancellationToken) + { + try + { + if (!File.Exists(_path)) + { + return new List(); + } + + await using var stream = new FileStream( + _path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + + return (await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken) + .ConfigureAwait(false)) ?? new List(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: could not read the record store."); + return new List(); + } + } + + private async Task SortAndSaveUnlockedAsync(List records, CancellationToken cancellationToken) + { + Directory.CreateDirectory(_directory); + + var ordered = records + .OrderByDescending(record => record.CreatedAtUtc) + .ThenBy(record => record.Id) + .ToList(); + + var tempPath = _path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync(stream, ordered, JsonOptions, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(tempPath, _path, overwrite: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: could not write the record store."); + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Best effort cleanup only. + } + + throw; + } + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Tasks/CleanupShareLinksScheduledTask.cs b/Jellyfin.Plugin.ShareLinks/Tasks/CleanupShareLinksScheduledTask.cs new file mode 100644 index 0000000..e99ff95 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Tasks/CleanupShareLinksScheduledTask.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.ShareLinks.Services; +using MediaBrowser.Model.Tasks; + +namespace Jellyfin.Plugin.ShareLinks.Tasks; + +/// +/// Scheduled cleanup shell for later link-expiry and guest-account teardown work. +/// +public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurableScheduledTask +{ + private readonly IShareLinkCleanupService _cleanupService; + + /// Initializes a new instance of the class. + public CleanupShareLinksScheduledTask(IShareLinkCleanupService cleanupService) + { + _cleanupService = cleanupService; + } + + /// + public string Name => "Clean up ShareLinks"; + + /// + public string Key => "ShareLinksCleanup"; + + /// + public string Description => "Removes expired share links and performs future guest-account cleanup."; + + /// + public string Category => "ShareLinks"; + + /// + public bool IsHidden => false; + + /// + public bool IsEnabled => true; + + /// + public bool IsLogged => true; + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + _ = progress; + await _cleanupService.CleanupAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public IEnumerable GetDefaultTriggers() + { + yield return new TaskTriggerInfo + { + Type = TaskTriggerInfoType.DailyTrigger, + TimeOfDayTicks = TimeSpan.FromHours(4).Ticks, + }; + } +} diff --git a/Jellyfin.Plugin.ShareLinks/Web/WebInjectionHostedService.cs b/Jellyfin.Plugin.ShareLinks/Web/WebInjectionHostedService.cs new file mode 100644 index 0000000..d65741f --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Web/WebInjectionHostedService.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.ShareLinks.Web; + +/// +/// Injects the ShareLinks client script into Jellyfin Web's index.html using +/// explicit markers so the edit can be applied and removed repeatedly without +/// drift. +/// +public sealed class WebInjectionHostedService : IHostedService +{ + private const string Begin = ""; + private const string End = ""; + + private readonly IServerApplicationPaths _paths; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + public WebInjectionHostedService(IServerApplicationPaths paths, ILogger logger) + { + _paths = paths; + _logger = logger; + } + + private string IndexPath => Path.Combine(_paths.WebPath, "index.html"); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + try + { + Inject(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ShareLinks: could not inject client script into web index.html."); + } + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + // Leave the marker in place. Some Jellyfin containers expose the web + // client as root-owned files: startup injection may need a deployment + // helper, and removing the marker on shutdown would make it vanish on + // every restart. + return Task.CompletedTask; + } + + private void Inject() + { + var path = IndexPath; + if (!File.Exists(path)) + { + _logger.LogWarning("ShareLinks: web index.html not found at {Path}.", path); + return; + } + + var html = File.ReadAllText(path); + if (html.Contains(Begin, StringComparison.Ordinal)) + { + return; + } + + var backup = path + ".sharelinks.bak"; + if (!File.Exists(backup)) + { + File.Copy(path, backup); + } + + var snippet = "\n" + Begin + "\n\n" + End + "\n"; + var bodyIndex = html.LastIndexOf("", StringComparison.OrdinalIgnoreCase); + html = bodyIndex >= 0 ? html.Insert(bodyIndex, snippet) : html + snippet; + + File.WriteAllText(path, html); + _logger.LogInformation("ShareLinks: injected client script into {Path}.", path); + } + +} diff --git a/Jellyfin.Plugin.ShareLinks/Web/configPage.html b/Jellyfin.Plugin.ShareLinks/Web/configPage.html new file mode 100644 index 0000000..f59307e --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Web/configPage.html @@ -0,0 +1,359 @@ + + + + ShareLinks + + + +
+
+
+
+
+
+

ShareLinks

+
+

+ Creates short-lived guest links for movies and episodes, then keeps the active records visible for revocation. +

+
+ +
+

General

+
+
+ +
+ +
+ +
+ +
+ +
Used by the menu action when the admin accepts the default.
+
+ +
+ +
+ +
+ +
Leave empty to derive the public URL from the current request.
+
+ +
+ +
+ +
+ +
+
+
+ +
+

Playback policy

+
+
+ +
+ +
+ +
+ +
+ +
Hides primary navigation for guest sessions in the web client. This is UX only, not the security boundary.
+
+
+
+ +
+
+

Share links

+ + Loading… +
+
+ + + + + + + + + + + + + + + +
StatusItemGuestExpiresActions
No links loaded yet.
+
+
+ +
+ +
+
+
+
+ + +
+ + diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js new file mode 100644 index 0000000..1620ce2 --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js @@ -0,0 +1,877 @@ +(function () { + var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6'; + var copyLabel = 'Copy Stream URL'; + var actionLabel = 'Create guest link'; + var clientVersion = '1.0.0-ui-modal-3'; + var allowedItemStorageKey = 'sharelinks.allowedItemId'; + var guestClassName = 'sharelinks-guest'; + var hiddenAttr = 'data-sharelinks-hidden'; + var injectedAttr = 'data-sharelinks-injected'; + var configPromise = null; + var userPromise = null; + var guestStatePromise = null; + var booted = false; + var scanQueued = false; + var bootRetry = null; + var observer = null; + var historyPatched = false; + var durationOptions = [ + { label: '1 hour', hours: 1 }, + { label: '2 hours', hours: 2 }, + { label: '4 hours', hours: 4 }, + { label: '6 hours', hours: 6 }, + { label: '12 hours', hours: 12 }, + { label: '1 day', hours: 24 }, + { label: '2 days', hours: 48 }, + { label: '7 days', hours: 168 }, + { label: '30 days', hours: 720 } + ]; + + window.ShareLinksClientVersion = clientVersion; + + function ready() { + return !!window.ApiClient && !!window.document && !!document.body; + } + + function start() { + if (booted) { + return; + } + + if (!ready()) { + if (!bootRetry) { + bootRetry = window.setTimeout(function () { + bootRetry = null; + start(); + }, 250); + } + return; + } + + booted = true; + installHooks(); + scheduleWork(); + } + + function installHooks() { + if (!historyPatched) { + historyPatched = true; + patchHistory(); + } + + window.addEventListener('hashchange', scheduleWork, true); + window.addEventListener('popstate', scheduleWork, true); + + observer = new MutationObserver(scheduleWork); + observer.observe(document.body, { childList: true, subtree: true }); + + window.setInterval(scheduleWork, 3000); + } + + function patchHistory() { + var pushState = history.pushState; + var replaceState = history.replaceState; + + history.pushState = function () { + var result = pushState.apply(this, arguments); + scheduleWork(); + return result; + }; + + history.replaceState = function () { + var result = replaceState.apply(this, arguments); + scheduleWork(); + return result; + }; + } + + function scheduleWork() { + if (scanQueued || !ready()) { + return; + } + + scanQueued = true; + window.requestAnimationFrame(function () { + scanQueued = false; + refresh().catch(function () { + // Best effort only. The menu hook should never block the web UI. + }); + }); + } + + async function refresh() { + rememberAllowedItemFromRoute(); + await applyGuestLockdown(); + await scanForMoreMenuActions(); + } + + function apiGet(path) { + return ApiClient.ajax({ + type: 'GET', + url: ApiClient.getUrl(path), + dataType: 'json' + }); + } + + function apiPost(path, body) { + return ApiClient.ajax({ + type: 'POST', + url: ApiClient.getUrl(path), + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify(body || {}) + }); + } + + function getConfig() { + if (!configPromise) { + configPromise = ApiClient.getPluginConfiguration(pluginId).catch(function () { + return {}; + }); + } + return configPromise; + } + + function getCurrentUser() { + if (!userPromise) { + userPromise = apiGet('Users/Me').catch(function () { + return null; + }); + } + return userPromise; + } + + function getGuestState() { + if (!guestStatePromise) { + guestStatePromise = apiGet('ShareLinks/GuestState').catch(function () { + return null; + }); + } + return guestStatePromise; + } + + async function applyGuestLockdown() { + var context = await getGuestContext(); + if (!context.locked) { + return; + } + + ensureGuestStyle(); + hideGuestControls(); + + // UX-only lockdown: the guest user's real access boundary is still the + // server-side policy and item tags. This just keeps the web client out + // of the user's way. + if (context.allowedItemId && !isAllowedLocation()) { + navigateToItem(context.allowedItemId); + } + } + + async function getGuestContext() { + var config = await getConfig(); + var user = await getCurrentUser(); + var state = await getGuestState(); + var prefix = config && config.GuestUsernamePrefix ? String(config.GuestUsernamePrefix) : 'share-'; + var username = user && user.Name ? String(user.Name) : ''; + var lockdownEnabled = config && config.GuestModeLockdownEnabled !== false; + if (state && state.lockdownEnabled === false) { + lockdownEnabled = false; + } + + var locked = lockdownEnabled && ( + (username && username.indexOf(prefix) === 0) + || !!(state && (state.IsGuest === true || state.isGuest === true || state.GuestUserId || state.guestUserId)) + ); + return { + locked: locked, + allowedItemId: extractAllowedItemId(state) || sessionStorage.getItem(allowedItemStorageKey) || null, + username: username, + prefix: prefix, + lockdownEnabled: lockdownEnabled + }; + } + + function extractAllowedItemId(state) { + if (!state) { + return null; + } + + return state.AllowedItemId || state.allowedItemId || state.ItemId || state.itemId || state.ShareItemId || state.shareItemId || null; + } + + function ensureGuestStyle() { + if (document.body.classList.contains(guestClassName)) { + return; + } + + document.body.classList.add(guestClassName); + if (document.getElementById('ShareLinksGuestStyle')) { + return; + } + + var style = document.createElement('style'); + style.id = 'ShareLinksGuestStyle'; + style.textContent = 'body.' + guestClassName + ' [' + hiddenAttr + '="1"] { display: none !important; }'; + document.head.appendChild(style); + } + + function hideGuestControls() { + var roots = [ + document.querySelector('.skinHeader'), + document.querySelector('.mainDrawer'), + document.querySelector('.mainDrawerPanel'), + document.querySelector('.pageContainer'), + document.body + ].filter(Boolean); + var keywords = ['home', 'search', 'library', 'settings', 'download', 'share']; + + roots.forEach(function (root) { + Array.from(root.querySelectorAll('button, a, [role="button"], [role="menuitem"]')).forEach(function (node) { + if (shouldHideNode(node, keywords)) { + node.setAttribute(hiddenAttr, '1'); + } + }); + }); + } + + function shouldHideNode(node, keywords) { + if (!node || node.getAttribute(hiddenAttr) === '1') { + return false; + } + + var label = getVisibleLabel(node).toLowerCase(); + if (!label) { + return false; + } + + return keywords.some(function (word) { + return label.indexOf(word) >= 0; + }); + } + + function getVisibleLabel(node) { + return normalizeText(node.getAttribute('aria-label') || node.getAttribute('title') || node.textContent || ''); + } + + function normalizeText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + async function scanForMoreMenuActions() { + var user = await getCurrentUser(); + if (!isAdministrator(user)) { + return; + } + + var copyNodes = []; + Array.from(document.querySelectorAll('button, a, [role="menuitem"], [role="option"], .actionsheetMenuItem, .paperListButton')).forEach(function (node) { + if (isCopyStreamUrlLabel(getVisibleLabel(node))) { + copyNodes.push(node); + insertActionAfter(node); + } + }); + + if (copyNodes.length === 0) { + insertIntoOpenMenuFallback(); + } + } + + function isCopyStreamUrlLabel(label) { + var value = normalizeText(label).toLowerCase(); + if (!value) { + return false; + } + + return value === copyLabel.toLowerCase() + || (value.indexOf('copy') >= 0 && value.indexOf('stream') >= 0 && value.indexOf('url') >= 0) + || (value.indexOf('copier') >= 0 && value.indexOf('url') >= 0 && (value.indexOf('flux') >= 0 || value.indexOf('stream') >= 0)); + } + + function insertIntoOpenMenuFallback() { + var itemId = resolveItemId(document); + if (!itemId) { + return; + } + + var container = findOpenActionContainer(); + if (!container || container.querySelector('[' + injectedAttr + '="1"]')) { + return; + } + + var template = findBestActionTemplate(container); + if (!template) { + return; + } + + insertActionAfter(template, itemId, true); + } + + function findOpenActionContainer() { + var selectors = [ + '.actionSheet', + '.actionsheet', + '.actionSheetContent', + '.dialog', + '.paperDialog', + '.mdl-dialog', + '.listItemBody', + '[role="dialog"]', + '[role="menu"]' + ]; + + for (var i = 0; i < selectors.length; i += 1) { + var nodes = document.querySelectorAll(selectors[i]); + for (var j = 0; j < nodes.length; j += 1) { + var node = nodes[j]; + if (isVisible(node) && looksLikeActionContainer(node)) { + return node; + } + } + } + + return null; + } + + function looksLikeActionContainer(node) { + var actions = Array.from(node.querySelectorAll('button, a, [role="menuitem"], [role="option"], .actionsheetMenuItem, .paperListButton')) + .filter(isVisible); + return actions.length >= 2 && actions.length <= 40; + } + + function findBestActionTemplate(container) { + var actions = Array.from(container.querySelectorAll('button, a, [role="menuitem"], [role="option"], .actionsheetMenuItem, .paperListButton')) + .filter(isVisible); + for (var i = 0; i < actions.length; i += 1) { + if (isCopyStreamUrlLabel(getVisibleLabel(actions[i]))) { + return actions[i]; + } + } + + return actions.length ? actions[actions.length - 1] : null; + } + + function isVisible(node) { + return !!(node && (node.offsetWidth || node.offsetHeight || node.getClientRects().length)); + } + + function insertActionAfter(copyNode, explicitItemId, appendToParent) { + var parent = copyNode.parentElement; + if (!parent || parent.querySelector('[' + injectedAttr + '="1"]')) { + return; + } + + var itemId = explicitItemId || resolveItemId(copyNode) || resolveItemId(document); + if (!itemId) { + return; + } + + var sourceLabel = getVisibleLabel(copyNode); + var injected = copyNode.cloneNode(true); + injected.setAttribute(injectedAttr, '1'); + injected.setAttribute('type', 'button'); + injected.removeAttribute('id'); + injected.removeAttribute('href'); + injected.removeAttribute('onclick'); + injected.removeAttribute('target'); + injected.removeAttribute('download'); + injected.setAttribute('aria-label', actionLabel); + injected.setAttribute('title', actionLabel); + injected.dataset.sharelinksItemId = itemId; + injected.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + void createGuestLink(itemId); + }, true); + + if (!replaceText(injected, sourceLabel, actionLabel)) { + injected.textContent = actionLabel; + } + + if (appendToParent) { + parent.appendChild(injected); + } else { + copyNode.insertAdjacentElement('afterend', injected); + } + } + + function replaceText(root, from, to) { + var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); + while (walker.nextNode()) { + var node = walker.currentNode; + if (normalizeText(node.nodeValue) === from) { + node.nodeValue = node.nodeValue.replace(from, to); + return true; + } + } + return false; + } + + function resolveItemId(node) { + var direct = parseItemIdFromUrl(); + if (direct) { + return direct; + } + + var current = node; + while (current && current !== document) { + var id = readItemIdFromNode(current); + if (id) { + return id; + } + current = current.parentElement; + } + + return findItemIdInDocument(); + } + + function parseItemIdFromUrl() { + var sources = [location.hash, location.search, location.href]; + for (var i = 0; i < sources.length; i += 1) { + var text = sources[i]; + var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i); + if (match && match[1]) { + return decodeURIComponent(match[1]); + } + } + + return null; + } + + function findItemIdInDocument() { + var selectors = [ + '#itemDetailPage', + '.detailPage', + '.itemDetailPage', + '[data-itemid]', + '[data-id]' + ]; + + for (var i = 0; i < selectors.length; i += 1) { + var nodes = document.querySelectorAll(selectors[i]); + for (var j = 0; j < nodes.length; j += 1) { + var id = readItemIdFromNode(nodes[j]); + if (id) { + return id; + } + } + } + + return null; + } + + function readItemIdFromNode(node) { + if (!node || !node.getAttribute) { + return null; + } + + return node.getAttribute('data-itemid') || node.getAttribute('data-id') || node.getAttribute('data-item-id') || null; + } + + function isAdministrator(user) { + return !!(user && user.Policy && user.Policy.IsAdministrator === true); + } + + function rememberAllowedItemFromRoute() { + if (!isDetailsOrPlaybackRoute()) { + return; + } + + var itemId = parseItemIdFromUrl() || findItemIdInDocument(); + if (itemId) { + sessionStorage.setItem(allowedItemStorageKey, itemId); + } + } + + function isDetailsOrPlaybackRoute() { + return /#!\/(?:details|playback|item)/i.test(location.hash || ''); + } + + function isAllowedLocation() { + return isDetailsOrPlaybackRoute(); + } + + function navigateToItem(itemId) { + var target = '#!/details?id=' + encodeURIComponent(itemId); + if (location.hash !== target) { + location.hash = target; + } + } + + async function createGuestLink(itemId) { + try { + var config = await getConfig(); + var user = await getCurrentUser(); + if (!isAdministrator(user)) { + notify('ShareLinks is available to administrators only.'); + return; + } + + if (config && config.Enabled === false) { + notify('ShareLinks is disabled.'); + return; + } + + var result = await chooseExpiryHours(config, function (expiryHours) { + var payload = { + itemId: itemId, + expiryHours: expiryHours, + oneUse: config && config.OneUseDefault !== undefined ? !!config.OneUseDefault : true + }; + + var shareUrlPromise = apiPost('ShareLinks/Admin/Create', payload).then(function (response) { + var shareUrl = response && (response.ShareUrl || response.shareUrl); + if (!shareUrl) { + throw new Error('The server did not return a share URL.'); + } + + return shareUrl; + }); + + var copiedPromise = copyTextWhenReady(shareUrlPromise); + return shareUrlPromise.then(function (shareUrl) { + return copiedPromise.catch(function () { + return false; + }).then(function (copied) { + return { + shareUrl: shareUrl, + copied: copied + }; + }); + }); + }); + if (!result) { + return; + } + + showShareResult(result.shareUrl, result.copied); + } catch (error) { + notify(extractErrorMessage(error, 'Could not create a guest link.')); + } + } + + function chooseExpiryHours(config, onChoose) { + var options = durationOptions.map(function (option) { + return { + label: option.label, + hours: option.hours + }; + }); + + return openModal({ + title: 'Create guest link', + body: 'Choose how long this link should stay valid.', + options: options, + onChoose: onChoose, + cancelText: 'Cancel' + }); + } + + function copyTextWhenReady(textPromise) { + if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob) { + try { + var blobPromise = Promise.resolve(textPromise).then(function (text) { + return new Blob([text], { type: 'text/plain' }); + }); + return navigator.clipboard.write([ + new ClipboardItem({ 'text/plain': blobPromise }) + ]).then(function () { + return true; + }).catch(function () { + return Promise.resolve(textPromise).then(copyText); + }); + } catch (error) { + return Promise.resolve(textPromise).then(copyText); + } + } + + return Promise.resolve(textPromise).then(copyText); + } + + function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).then(function () { + return true; + }).catch(function () { + return fallbackCopy(text); + }); + } + + return Promise.resolve(fallbackCopy(text)); + } + + function notify(message) { + ensureShareLinksUi(); + var toast = document.createElement('div'); + toast.className = 'sharelinks-toast'; + toast.textContent = message; + document.body.appendChild(toast); + window.setTimeout(function () { + toast.classList.add('is-visible'); + }, 20); + window.setTimeout(function () { + toast.classList.remove('is-visible'); + window.setTimeout(function () { + toast.remove(); + }, 180); + }, 3600); + } + + function showShareResult(shareUrl, copied) { + ensureShareLinksUi(); + var body = document.createElement('div'); + var note = document.createElement('p'); + note.className = 'sharelinks-note'; + note.textContent = copied + ? 'The link was copied to your clipboard.' + : 'The link was created, but the browser blocked automatic clipboard access.'; + body.appendChild(note); + + var urlBox = document.createElement('textarea'); + urlBox.className = 'sharelinks-url'; + urlBox.readOnly = true; + urlBox.value = shareUrl; + body.appendChild(urlBox); + + openModalElement({ + title: copied ? 'Share link copied' : 'Share link created', + bodyElement: body, + actions: [ + { + label: 'Copy', + primary: true, + handler: function () { + return copyText(shareUrl).then(function (ok) { + if (ok) { + notify('Share link copied.'); + } else { + urlBox.focus(); + urlBox.select(); + notify('Select and copy the link manually.'); + } + return ok; + }); + } + }, + { + label: 'Done', + close: true + } + ], + onOpen: function () { + urlBox.focus(); + urlBox.select(); + } + }); + } + + function openModal(settings) { + var body = document.createElement('div'); + var text = document.createElement('p'); + text.className = 'sharelinks-note'; + text.textContent = settings.body; + body.appendChild(text); + + var grid = document.createElement('div'); + grid.className = 'sharelinks-duration-grid'; + body.appendChild(grid); + + return new Promise(function (resolve) { + var modal = openModalElement({ + title: settings.title, + bodyElement: body, + actions: [ + { + label: settings.cancelText || 'Cancel', + close: true, + handler: function () { + resolve(null); + } + } + ], + onDismiss: function () { + resolve(null); + } + }); + + settings.options.forEach(function (option) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'sharelinks-duration-button'; + button.textContent = option.label; + + button.addEventListener('click', function () { + modal.close(); + if (settings.onChoose) { + resolve(settings.onChoose(option.hours)); + } else { + resolve(option.hours); + } + }); + grid.appendChild(button); + }); + }); + } + + function openModalElement(settings) { + ensureShareLinksUi(); + var closed = false; + var overlay = document.createElement('div'); + overlay.className = 'sharelinks-overlay'; + overlay.setAttribute('role', 'presentation'); + + var dialog = document.createElement('div'); + dialog.className = 'sharelinks-dialog'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-label', settings.title); + overlay.appendChild(dialog); + + var title = document.createElement('h3'); + title.className = 'sharelinks-title'; + title.textContent = settings.title; + dialog.appendChild(title); + + dialog.appendChild(settings.bodyElement); + + var actions = document.createElement('div'); + actions.className = 'sharelinks-actions'; + dialog.appendChild(actions); + + function close() { + if (closed) { + return; + } + closed = true; + overlay.classList.remove('is-visible'); + window.setTimeout(function () { + overlay.remove(); + }, 160); + } + + (settings.actions || []).forEach(function (action) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = action.primary ? 'sharelinks-action primary' : 'sharelinks-action'; + button.textContent = action.label; + button.addEventListener('click', function () { + var result = action.handler ? action.handler() : null; + Promise.resolve(result).finally(function () { + if (action.close) { + close(); + } + }); + }); + actions.appendChild(button); + }); + + overlay.addEventListener('click', function (event) { + if (event.target === overlay) { + close(); + if (settings.onDismiss) { + settings.onDismiss(); + } + } + }); + document.addEventListener('keydown', function onKeyDown(event) { + if (event.key === 'Escape' && !closed) { + document.removeEventListener('keydown', onKeyDown, true); + close(); + if (settings.onDismiss) { + settings.onDismiss(); + } + } + }, true); + + document.body.appendChild(overlay); + window.setTimeout(function () { + overlay.classList.add('is-visible'); + var firstButton = overlay.querySelector('button:not([disabled])'); + if (firstButton) { + firstButton.focus(); + } + if (settings.onOpen) { + settings.onOpen(); + } + }, 20); + + return { close: close, element: overlay }; + } + + function ensureShareLinksUi() { + if (document.getElementById('ShareLinksUiStyle')) { + return; + } + + var style = document.createElement('style'); + style.id = 'ShareLinksUiStyle'; + style.textContent = [ + '.sharelinks-overlay{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.58);display:grid;place-items:center;padding:24px;opacity:0;transition:opacity .16s ease;}', + '.sharelinks-overlay.is-visible{opacity:1;}', + '.sharelinks-dialog{width:min(520px,100%);background:var(--background-color,#202020);color:var(--text-color,#fff);box-shadow:0 18px 60px rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:22px;}', + '.sharelinks-title{font-size:1.25rem;line-height:1.3;margin:0 0 14px;font-weight:600;}', + '.sharelinks-note{margin:0 0 16px;color:var(--text-secondary-color,#cfcfcf);line-height:1.45;}', + '.sharelinks-duration-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:4px;}', + '.sharelinks-duration-button,.sharelinks-action{border:0;border-radius:6px;background:rgba(255,255,255,.12);color:inherit;padding:10px 12px;min-height:42px;cursor:pointer;font:inherit;}', + '.sharelinks-duration-button:hover:not(:disabled),.sharelinks-action:hover{background:rgba(255,255,255,.18);}', + '.sharelinks-duration-button:disabled{opacity:.35;cursor:not-allowed;}', + '.sharelinks-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px;}', + '.sharelinks-action.primary{background:var(--theme-primary-color,#00a4dc);color:#fff;}', + '.sharelinks-url{width:100%;min-height:88px;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px;font:inherit;resize:vertical;}', + '.sharelinks-toast{position:fixed;left:24px;bottom:24px;z-index:1000000;max-width:min(460px,calc(100vw - 48px));background:rgba(24,24,24,.96);color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:6px;padding:11px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;}', + '.sharelinks-toast.is-visible{opacity:1;transform:translateY(0);}', + '@media (max-width:520px){.sharelinks-duration-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.sharelinks-dialog{padding:18px;}.sharelinks-actions{justify-content:stretch;}.sharelinks-action{flex:1;}}' + ].join(''); + document.head.appendChild(style); + } + + function fallbackCopy(text) { + var textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.top = '-1000px'; + textarea.style.left = '-1000px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + var copied = false; + try { + copied = document.execCommand('copy'); + } catch (error) { + copied = false; + } + textarea.remove(); + return copied; + } + + function clampPositiveInteger(value, fallback) { + var parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + function extractErrorMessage(error, fallback) { + if (!error) { + return fallback; + } + + if (typeof error === 'string') { + return error; + } + + if (error.responseJSON && error.responseJSON.error) { + return error.responseJSON.error; + } + + if (error.responseText) { + return error.responseText; + } + + if (error.message) { + return error.message; + } + + return fallback; + } + + start(); +})(); diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json new file mode 100644 index 0000000..4ac2fed --- /dev/null +++ b/Jellyfin.Plugin.ShareLinks/meta.json @@ -0,0 +1,12 @@ +{ + "guid": "68540b76-ee74-436d-85ff-2abc884bbea6", + "name": "ShareLinks", + "version": "1.0.0.0", + "targetAbi": "10.11.0.0", + "framework": "net9.0", + "owner": "Franciskid", + "overview": "Secure expiring guest-share links for Jellyfin items.", + "description": "Adds secure, expiring share links for Jellyfin items with JSON-backed storage, token hashing, and cleanup scaffolding.", + "category": "General", + "timestamp": "2026-07-06T00:00:00.0000000Z" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e39534 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# ShareLinks + +ShareLinks is a Jellyfin 10.11 plugin scaffold for issuing expiring guest-share +links to individual items. This staging tree now includes the core dashboard +and web-client wiring that later workers will build on: + +- plugin metadata and DI registration +- JSON-backed record storage +- token generation and hashing +- startup and scheduled cleanup shells +- configuration defaults +- Jellyfin Web script injection and a dashboard config page shell + +## Current security stance + +The design goal is simple: a raw share token should exist only at the moment it +is issued, returned to the caller once, and then forgotten. Persistent storage +keeps only a keyed HMAC hash of the token plus the metadata needed to audit or +clean up the link. + +That means later API work must keep a few rules: + +1. never log raw tokens +2. never write raw tokens to disk +3. only return the token in the initial creation response +4. treat token validation as hash comparison only +5. keep guest-user creation and teardown behind explicit service calls + +## Configuration plan + +The `PluginConfiguration` defaults are deliberately opinionated: + +- default expiry hours +- maximum allowed expiry hours +- optional public base URL override +- guest username prefix +- transcoding and remuxing toggles +- cleanup interval in minutes +- one-use default +- guest-mode lockdown enabled by default + +Later workers should wire those settings into the issue / redeem / revoke +pipeline and into the guest-user creation logic. + +## Storage layout + +The staging implementation stores plugin data under Jellyfin's application data +path, in a dedicated `sharelinks` directory. The persistent JSON store keeps +`ShareLinkRecord` entries keyed by id, while the token service stores its secret +key separately in the same directory. + +This keeps the plugin portable and avoids any hardcoded filesystem locations. + +## Cleanup architecture + +There are two cleanup entry points already wired: + +- `Tasks/CleanupShareLinksScheduledTask.cs` +- `Lifecycle/StartupCleanupHostedService.cs` + +Both currently call an `IShareLinkCleanupService` implementation that is a +no-op. That gives later workers a stable seam for: + +- expiring old links +- removing one-use links after redemption +- tearing down guest accounts and tokens +- recording cleanup attempts and failures + +## Endpoint audit requirements + +When the API surface is added, it should be audited for: + +- authz on every create/list/redeem/revoke endpoint +- exact token handling on create and redeem flows +- rate limiting for token guesses and redemption retries +- whether any response leaks the token hash, raw token, or guest credentials +- whether guest-mode lockdown is enforced consistently +- whether cleanup can safely run while links are being created or redeemed +- whether error messages reveal link existence or status + +## What is intentionally missing + +The remaining work is mostly policy polish and cleanup edge cases. The plugin +already has its controller, web injection hook, and dashboard page entry point +in place. diff --git a/ShareLinks.sln b/ShareLinks.sln new file mode 100644 index 0000000..953b0e6 --- /dev/null +++ b/ShareLinks.sln @@ -0,0 +1,19 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ShareLinks", "Jellyfin.Plugin.ShareLinks\Jellyfin.Plugin.ShareLinks.csproj", "{03D33F07-50D3-4CE8-876F-63638614B25C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {03D33F07-50D3-4CE8-876F-63638614B25C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03D33F07-50D3-4CE8-876F-63638614B25C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03D33F07-50D3-4CE8-876F-63638614B25C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03D33F07-50D3-4CE8-876F-63638614B25C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..9819f32 --- /dev/null +++ b/manifest.json @@ -0,0 +1,21 @@ +[ + { + "guid": "68540b76-ee74-436d-85ff-2abc884bbea6", + "name": "ShareLinks", + "description": "Adds secure, expiring guest-share links for Jellyfin items using temporary hidden users, one-use token redemption, restrictive policies, and scheduled cleanup.", + "overview": "Temporary guest links for Jellyfin movies and episodes.", + "owner": "Franciskid", + "category": "General", + "imageUrl": "", + "versions": [ + { + "version": "1.0.0.0", + "changelog": "Initial hybrid ShareLinks build with admin web menu integration, token redemption, temporary guest users, and cleanup.", + "targetAbi": "10.11.0.0", + "sourceUrl": "", + "checksum": "", + "timestamp": "2026-07-06T00:00:00Z" + } + ] + } +]