ShareLinks plugin: guest share links for Jellyfin

Includes fix for redemption failing with DbUpdateConcurrencyException:
change the guest password before applying the user policy, since
UpdatePolicyAsync bumps the user's EF concurrency token and a stale
instance then breaks ChangePassword.
Cette révision appartient à :
Franciskid
2026-07-06 18:21:22 +02:00
révision c29ea7f20c
26 fichiers modifiés avec 3804 ajouts et 0 suppressions
+374
Voir le fichier
@@ -0,0 +1,374 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Configuration;
using Jellyfin.Plugin.ShareLinks.Models;
using Jellyfin.Plugin.ShareLinks.Services;
using Jellyfin.Plugin.ShareLinks.Storage;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Api;
/// <summary>Request body for ShareLinks admin creation.</summary>
public sealed class ShareLinkCreateRequest
{
/// <summary>Gets or sets the Jellyfin item id.</summary>
public string? ItemId { get; set; }
/// <summary>Gets or sets an optional expiry in hours.</summary>
public int? ExpiryHours { get; set; }
/// <summary>Gets or sets whether the link may be redeemed once only.</summary>
public bool? OneUse { get; set; }
}
/// <summary>Admin response for a created ShareLinks record.</summary>
public sealed class ShareLinkCreateResponse
{
/// <summary>Gets or sets the raw share URL.</summary>
public string ShareUrl { get; set; } = string.Empty;
/// <summary>Gets or sets the created record snapshot.</summary>
public ShareLinkAdminRecordDto Record { get; set; } = new();
}
/// <summary>DTO returned by admin list and revoke endpoints.</summary>
public sealed class ShareLinkAdminRecordDto
{
public Guid Id { get; set; }
public string ItemId { get; set; } = string.Empty;
public string ItemNameSnapshot { get; set; } = string.Empty;
public string? LibraryId { get; set; }
public Guid? CreatedByUserId { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset? RedeemedAtUtc { get; set; }
public DateTimeOffset ExpiresAtUtc { get; set; }
public ShareLinkStatus Status { get; set; }
public Guid? GuestUserId { get; set; }
public string? GuestUserName { get; set; }
public string? AllowedTag { get; set; }
public bool OneUse { get; set; }
public bool MetadataTouched { get; set; }
public int CleanupAttempts { get; set; }
public string? CleanupError { get; set; }
}
/// <summary>Guest session state returned to the web client.</summary>
public sealed class ShareLinkGuestStateDto
{
public bool IsGuest { get; set; }
public string? AllowedItemId { get; set; }
public Guid? ShareId { get; set; }
public DateTimeOffset? ExpiresAtUtc { get; set; }
public bool LockdownEnabled { get; set; }
}
/// <summary>ShareLinks API surface.</summary>
[ApiController]
[Route("ShareLinks")]
public sealed class ShareLinksController : ControllerBase
{
private readonly ILibraryManager _libraryManager;
private readonly ShareLinkCreationService _creationService;
private readonly ShareLinkCleanupService _cleanupService;
private readonly ShareLinkRedemptionService _redemptionService;
private readonly ShareLinkStore _store;
private readonly ILogger<ShareLinksController> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinksController"/> class.</summary>
public ShareLinksController(
ILibraryManager libraryManager,
ShareLinkCreationService creationService,
ShareLinkCleanupService cleanupService,
ShareLinkRedemptionService redemptionService,
ShareLinkStore store,
ILogger<ShareLinksController> logger)
{
_libraryManager = libraryManager;
_creationService = creationService;
_cleanupService = cleanupService;
_redemptionService = redemptionService;
_store = store;
_logger = logger;
}
private static PluginConfiguration Config => Plugin.Instance!.Configuration;
/// <summary>Serves the client-side ShareLinks script.</summary>
[HttpGet("ClientScript")]
[AllowAnonymous]
public ActionResult ClientScript()
{
SetNoStoreHeaders();
var assembly = typeof(ShareLinksController).Assembly;
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(name => name.EndsWith(".Web.sharelinks.js", StringComparison.OrdinalIgnoreCase));
if (resourceName is null)
{
return NotFound();
}
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
return NotFound();
}
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return Content(reader.ReadToEnd(), "application/javascript; charset=utf-8");
}
/// <summary>Creates a new share link for an item.</summary>
[HttpPost("Admin/Create")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult<ShareLinkCreateResponse>> Create([FromBody] ShareLinkCreateRequest request, CancellationToken cancellationToken)
{
SetNoStoreHeaders();
if (!User.IsInRole("Administrator"))
{
return Forbid();
}
var config = Config;
if (!config.Enabled)
{
return StatusCode(503, new { error = "ShareLinks is disabled." });
}
if (request is null || string.IsNullOrWhiteSpace(request.ItemId))
{
return BadRequest(new { error = "Missing itemId." });
}
if (!Guid.TryParse(request.ItemId, out var itemId))
{
return BadRequest(new { error = "Invalid itemId." });
}
var expiryHours = request.ExpiryHours ?? config.DefaultExpiryHours;
if (expiryHours <= 0)
{
return BadRequest(new { error = "Expiry must be positive." });
}
var effectiveMaxExpiryHours = Math.Max(config.MaxExpiryHours, 720);
if (expiryHours > effectiveMaxExpiryHours)
{
return BadRequest(new { error = $"Expiry exceeds the configured maximum of {effectiveMaxExpiryHours} hours." });
}
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
return NotFound(new { error = "Item not found." });
}
try
{
var creatorUserId = GetCurrentUserId();
var oneUse = request.OneUse ?? config.OneUseDefault;
var creation = await _creationService.CreateAsync(item, creatorUserId, expiryHours, oneUse, cancellationToken).ConfigureAwait(false);
var shareUrl = BuildShareUrl(Request, creation.RawToken);
return Ok(new ShareLinkCreateResponse
{
ShareUrl = shareUrl,
Record = ToDto(creation.Record)
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: create failed for item {ItemId}.", itemId);
return StatusCode(500, new { error = "Failed to create share link." });
}
}
/// <summary>Lists all share links for administrators.</summary>
[HttpGet("Admin/List")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult<IEnumerable<ShareLinkAdminRecordDto>>> List(CancellationToken cancellationToken)
{
SetNoStoreHeaders();
if (!User.IsInRole("Administrator"))
{
return Forbid();
}
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
return Ok(records.Select(ToDto).ToArray());
}
/// <summary>Revokes a share link and triggers cleanup.</summary>
[HttpPost("Admin/Revoke/{id:guid}")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult<ShareLinkAdminRecordDto>> Revoke(Guid id, CancellationToken cancellationToken)
{
SetNoStoreHeaders();
if (!User.IsInRole("Administrator"))
{
return Forbid();
}
var record = await _cleanupService.RevokeAsync(id, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return NotFound(new { error = "Share link not found." });
}
return Ok(ToDto(record));
}
/// <summary>Returns the guest session state for the current authenticated user.</summary>
[HttpGet("GuestState")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public async Task<ActionResult<ShareLinkGuestStateDto>> GuestState(CancellationToken cancellationToken)
{
SetNoStoreHeaders();
var config = Config;
var currentUserId = GetCurrentUserId();
var currentUserName = GetCurrentUserName();
if (currentUserId != Guid.Empty || !string.IsNullOrWhiteSpace(currentUserName))
{
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
var match = records.FirstOrDefault(record =>
!IsExpired(record) &&
IsGuestSessionStatus(record.Status) &&
(
(currentUserId != Guid.Empty && record.GuestUserId.HasValue && record.GuestUserId.Value == currentUserId) ||
(!string.IsNullOrWhiteSpace(currentUserName) &&
!string.IsNullOrWhiteSpace(record.GuestUserName) &&
string.Equals(record.GuestUserName, currentUserName, StringComparison.OrdinalIgnoreCase))
));
if (match is not null)
{
return Ok(new ShareLinkGuestStateDto
{
IsGuest = true,
AllowedItemId = match.ItemId,
ShareId = match.Id,
ExpiresAtUtc = match.ExpiresAtUtc,
LockdownEnabled = config.GuestModeLockdownEnabled
});
}
}
return Ok(new ShareLinkGuestStateDto
{
IsGuest = false,
LockdownEnabled = config.GuestModeLockdownEnabled
});
}
/// <summary>Redeems a share link token and returns the bootstrap login page.</summary>
[HttpGet("Redeem")]
[AllowAnonymous]
public async Task<ActionResult> Redeem([FromQuery(Name = "t")] string? token, CancellationToken cancellationToken)
{
SetNoStoreHeaders();
if (string.IsNullOrWhiteSpace(token))
{
return NotFound();
}
var html = await _redemptionService.RedeemAsync(token, Request, cancellationToken).ConfigureAwait(false);
if (html is null)
{
return NotFound();
}
return Content(html, "text/html; charset=utf-8");
}
private static ShareLinkAdminRecordDto ToDto(ShareLinkRecord record)
{
return new ShareLinkAdminRecordDto
{
Id = record.Id,
ItemId = record.ItemId,
ItemNameSnapshot = record.ItemNameSnapshot,
LibraryId = record.LibraryId,
CreatedByUserId = record.CreatedByUserId,
CreatedAtUtc = record.CreatedAtUtc,
RedeemedAtUtc = record.RedeemedAtUtc,
ExpiresAtUtc = record.ExpiresAtUtc,
Status = record.Status,
GuestUserId = record.GuestUserId,
GuestUserName = record.GuestUserName,
AllowedTag = record.AllowedTag,
OneUse = record.OneUse,
MetadataTouched = record.MetadataTouched,
CleanupAttempts = record.CleanupAttempts,
CleanupError = record.CleanupError
};
}
private Guid GetCurrentUserId()
{
var claim = User.FindFirst("Jellyfin-UserId")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
}
private string? GetCurrentUserName()
{
return User.FindFirst("Jellyfin-UserName")?.Value
?? User.FindFirst(ClaimTypes.Name)?.Value
?? User.Identity?.Name;
}
private static bool IsExpired(ShareLinkRecord record)
{
return record.ExpiresAtUtc <= DateTimeOffset.UtcNow;
}
private static bool IsGuestSessionStatus(ShareLinkStatus status)
{
return status is ShareLinkStatus.Active or ShareLinkStatus.Redeeming or ShareLinkStatus.Redeemed;
}
private void SetNoStoreHeaders()
{
Response.Headers["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
}
private static string BuildShareUrl(Microsoft.AspNetCore.Http.HttpRequest request, string rawToken)
{
var config = Config;
var baseUrl = string.IsNullOrWhiteSpace(config.PublicBaseUrlOverride)
? $"{request.Scheme}://{request.Host}{request.PathBase}"
: config.PublicBaseUrlOverride.TrimEnd('/');
return $"{baseUrl.TrimEnd('/')}/ShareLinks/Redeem?t={Uri.EscapeDataString(rawToken)}";
}
}
+44
Voir le fichier
@@ -0,0 +1,44 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.ShareLinks.Configuration;
/// <summary>
/// Plugin configuration persisted by Jellyfin.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>Gets or sets a value indicating whether the plugin is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Gets or sets the default share expiry in hours.</summary>
public int DefaultExpiryHours { get; set; } = 24;
/// <summary>Gets or sets the maximum allowed share expiry in hours.</summary>
public int MaxExpiryHours { get; set; } = 720;
/// <summary>
/// Gets or sets an override for the public base URL used when building
/// absolute share links. Empty means "derive from the incoming request".
/// </summary>
public string PublicBaseUrlOverride { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the prefix used when creating guest user names.
/// </summary>
public string GuestUsernamePrefix { get; set; } = "share-";
/// <summary>Gets or sets a value indicating whether shares may transcode.</summary>
public bool AllowTranscoding { get; set; } = true;
/// <summary>Gets or sets a value indicating whether shares may remux.</summary>
public bool AllowRemuxing { get; set; } = true;
/// <summary>Gets or sets the cleanup interval, in minutes.</summary>
public int CleanupIntervalMinutes { get; set; } = 60;
/// <summary>Gets or sets a value indicating whether links default to one use.</summary>
public bool OneUseDefault { get; set; } = true;
/// <summary>Gets or sets a value indicating whether guest-mode lockdown is enabled.</summary>
public bool GuestModeLockdownEnabled { get; set; } = true;
}
+25
Voir le fichier
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
<Version>1.0.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Web\**\*.*" />
</ItemGroup>
</Project>
+42
Voir le fichier
@@ -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;
/// <summary>
/// Runs one cleanup pass at startup so stale records do not linger forever.
/// </summary>
public sealed class StartupCleanupHostedService : BackgroundService
{
private readonly IShareLinkCleanupService _cleanupService;
private readonly ILogger<StartupCleanupHostedService> _logger;
/// <summary>Initializes a new instance of the <see cref="StartupCleanupHostedService"/> class.</summary>
public StartupCleanupHostedService(
IShareLinkCleanupService cleanupService,
ILogger<StartupCleanupHostedService> logger)
{
_cleanupService = cleanupService;
_logger = logger;
}
/// <inheritdoc />
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.");
}
}
}
+70
Voir le fichier
@@ -0,0 +1,70 @@
using System;
namespace Jellyfin.Plugin.ShareLinks.Models;
/// <summary>
/// Persistent share-link record. Only the token hash is stored; the raw token
/// never enters durable storage.
/// </summary>
public sealed class ShareLinkRecord
{
/// <summary>Gets or sets the share-link id.</summary>
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Gets or sets the HMAC hash of the token.</summary>
public string TokenHash { get; set; } = string.Empty;
/// <summary>Gets or sets the Jellyfin item id snapshot.</summary>
public string ItemId { get; set; } = string.Empty;
/// <summary>Gets or sets the Jellyfin item name snapshot.</summary>
public string ItemNameSnapshot { get; set; } = string.Empty;
/// <summary>Gets or sets the library id snapshot.</summary>
public string? LibraryId { get; set; }
/// <summary>Gets or sets the user id that created the link.</summary>
public Guid? CreatedByUserId { get; set; }
/// <summary>Gets or sets the UTC creation time.</summary>
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
/// <summary>Gets or sets the UTC redemption time, if any.</summary>
public DateTimeOffset? RedeemedAtUtc { get; set; }
/// <summary>Gets or sets the UTC expiry time.</summary>
public DateTimeOffset ExpiresAtUtc { get; set; }
/// <summary>Gets or sets the current lifecycle status.</summary>
public ShareLinkStatus Status { get; set; } = ShareLinkStatus.Pending;
/// <summary>Gets or sets the guest user id associated with the link.</summary>
public Guid? GuestUserId { get; set; }
/// <summary>Gets or sets the guest user name associated with the link.</summary>
public string? GuestUserName { get; set; }
/// <summary>Gets or sets the access token id used by the guest session, if available.</summary>
public string? AccessTokenId { get; set; }
/// <summary>Gets or sets the device id used by the guest session, if available.</summary>
public string? DeviceId { get; set; }
/// <summary>Gets or sets the allowed tag snapshot, if any.</summary>
public string? AllowedTag { get; set; }
/// <summary>Gets or sets a value indicating whether the link may be used once only.</summary>
public bool OneUse { get; set; } = true;
/// <summary>Gets or sets the encrypted guest password, if one has been generated.</summary>
public string? GuestPasswordEncrypted { get; set; }
/// <summary>Gets or sets a value indicating whether metadata was touched during cleanup.</summary>
public bool MetadataTouched { get; set; }
/// <summary>Gets or sets the number of cleanup attempts performed on this record.</summary>
public int CleanupAttempts { get; set; }
/// <summary>Gets or sets the last cleanup error, if any.</summary>
public string? CleanupError { get; set; }
}
+13
Voir le fichier
@@ -0,0 +1,13 @@
namespace Jellyfin.Plugin.ShareLinks.Models;
/// <summary>Lifecycle state of a share link.</summary>
public enum ShareLinkStatus
{
Pending = 0,
Active = 1,
Redeemed = 2,
Expired = 3,
Revoked = 4,
Failed = 5,
Redeeming = 6
}
+13
Voir le fichier
@@ -0,0 +1,13 @@
namespace Jellyfin.Plugin.ShareLinks.Models;
/// <summary>
/// A freshly generated share token and its persisted hash.
/// </summary>
public sealed class ShareTokenMaterial
{
/// <summary>Gets or sets the raw token returned once to the caller.</summary>
public string Token { get; set; } = string.Empty;
/// <summary>Gets or sets the HMAC hash stored durably.</summary>
public string TokenHash { get; set; } = string.Empty;
}
+46
Voir le fichier
@@ -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;
/// <summary>
/// ShareLinks plugin. Creates expiring guest-share links for Jellyfin items
/// without persisting raw tokens.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>Initializes a new instance of the <see cref="Plugin"/> class.</summary>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>Gets the current plugin instance.</summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public override string Name => "ShareLinks";
/// <inheritdoc />
public override string Description =>
"Secure expiring share links for Jellyfin items with guest-user lockdown.";
/// <inheritdoc />
public override Guid Id => Guid.Parse("68540b76-ee74-436d-85ff-2abc884bbea6");
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages() => new[]
{
new PluginPageInfo
{
Name = "ShareLinks",
EmbeddedResourcePath = GetType().Namespace + ".Web.configPage.html"
}
};
}
+33
Voir le fichier
@@ -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;
/// <summary>
/// Registers the foundational ShareLinks services used by later API and web
/// workers.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
_ = applicationHost;
serviceCollection.AddHostedService<WebInjectionHostedService>();
serviceCollection.AddSingleton<ShareLinkStore>();
serviceCollection.AddSingleton<ShareTokenService>();
serviceCollection.AddSingleton<ItemTagService>();
serviceCollection.AddSingleton<JellyfinGuestUserService>();
serviceCollection.AddSingleton<ShareLinkCreationService>();
serviceCollection.AddSingleton<ShareLinkRedemptionService>();
serviceCollection.AddSingleton<ShareLinkCleanupService>();
serviceCollection.AddSingleton<IShareLinkCleanupService>(provider => provider.GetRequiredService<ShareLinkCleanupService>());
serviceCollection.AddHostedService<StartupCleanupHostedService>();
}
}
+13
Voir le fichier
@@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>
/// Cleanup seam for later workers. The initial implementation is a no-op.
/// </summary>
public interface IShareLinkCleanupService
{
/// <summary>Runs one cleanup pass.</summary>
Task CleanupAsync(CancellationToken cancellationToken);
}
+122
Voir le fichier
@@ -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;
/// <summary>Applies and removes temporary tags on shared items.</summary>
public sealed class ItemTagService
{
private readonly ILibraryManager _libraryManager;
private readonly ILogger<ItemTagService> _logger;
/// <summary>Initializes a new instance of the <see cref="ItemTagService"/> class.</summary>
public ItemTagService(ILibraryManager libraryManager, ILogger<ItemTagService> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
/// <summary>Ensures the supplied tag is present on the item and persisted.</summary>
public async Task<bool> 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<string>();
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;
}
/// <summary>Removes the supplied tag from the item and persists the change.</summary>
public async Task<bool> 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<string>();
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);
}
}
+514
Voir le fichier
@@ -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;
/// <summary>Creates and tears down temporary Jellyfin guest users.</summary>
public sealed class JellyfinGuestUserService
{
private readonly IUserManager _userManager;
private readonly ILogger<JellyfinGuestUserService> _logger;
/// <summary>Initializes a new instance of the <see cref="JellyfinGuestUserService"/> class.</summary>
public JellyfinGuestUserService(IUserManager userManager, ILogger<JellyfinGuestUserService> logger)
{
_userManager = userManager;
_logger = logger;
}
/// <summary>Builds the temporary guest username for a share record.</summary>
public static string BuildGuestUsername(ShareLinkRecord record)
{
var prefix = Plugin.Instance?.Configuration.GuestUsernamePrefix ?? "share-";
return $"{prefix}{record.Id:N}";
}
/// <summary>Generates a strong random password suitable for a temporary guest user.</summary>
public static string GeneratePassword()
{
var bytes = new byte[32];
RandomNumberGenerator.Fill(bytes);
return Base64UrlEncode(bytes);
}
/// <summary>Ensures the temporary guest user exists and has the correct policy and password.</summary>
public async Task<dynamic> 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<object?>(
"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;
}
/// <summary>Disables a temporary guest user before deletion.</summary>
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);
}
}
/// <summary>Deletes a temporary guest user if it exists.</summary>
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<string>() : new[] { record.AllowedTag! });
SetPolicyValue(policy, "BlockedTags", Array.Empty<string>());
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<string>());
SetPolicyValue(policy, "EnableContentDownloading", false);
SetPolicyValue(policy, "EnableSyncTranscoding", false);
SetPolicyValue(policy, "EnableMediaConversion", false);
SetPolicyValue(policy, "EnableAllChannels", false);
SetPolicyValue(policy, "EnabledChannels", Array.Empty<Guid>());
SetPolicyValue(policy, "EnableAllDevices", true);
SetPolicyValue(policy, "EnabledDevices", Array.Empty<string>());
SetPolicyValue(policy, "EnableAllFolders", true);
SetPolicyValue(policy, "EnabledFolders", Array.Empty<Guid>());
SetPolicyValue(policy, "EnablePublicSharing", false);
SetPolicyValue(policy, "LoginAttemptsBeforeLockout", -1);
SetPolicyValue(policy, "MaxActiveSessions", 1);
SetPolicyValue(policy, "BlockUnratedItems", Array.Empty<Jellyfin.Data.Enums.UnratedItem>());
await InvokeUserManagerAsync<object?>(
"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<object?>(
"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<object?>(
"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<T?> InvokeUserManagerAsync<T>(
string operationName,
CancellationToken cancellationToken,
params InvocationCandidate[] candidates)
{
var managerType = _userManager.GetType();
var triedVariants = new List<string>();
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<object?>();
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<object?>();
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<object?>();
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<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
+17
Voir le fichier
@@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>
/// Temporary cleanup implementation used until the real cleanup pipeline lands.
/// </summary>
public sealed class NoOpShareLinkCleanupService : IShareLinkCleanupService
{
/// <inheritdoc />
public Task CleanupAsync(CancellationToken cancellationToken)
{
_ = cancellationToken;
return Task.CompletedTask;
}
}
+173
Voir le fichier
@@ -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;
/// <summary>Cleanly expires links and tears down temporary guest state.</summary>
public sealed class ShareLinkCleanupService : IShareLinkCleanupService
{
private readonly ShareLinkStore _store;
private readonly ILibraryManager _libraryManager;
private readonly ItemTagService _itemTagService;
private readonly JellyfinGuestUserService _guestUserService;
private readonly ILogger<ShareLinkCleanupService> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinkCleanupService"/> class.</summary>
public ShareLinkCleanupService(
ShareLinkStore store,
ILibraryManager libraryManager,
ItemTagService itemTagService,
JellyfinGuestUserService guestUserService,
ILogger<ShareLinkCleanupService> logger)
{
_store = store;
_libraryManager = libraryManager;
_itemTagService = itemTagService;
_guestUserService = guestUserService;
_logger = logger;
}
/// <inheritdoc />
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);
}
}
/// <summary>Revokes a specific share link and immediately runs teardown.</summary>
public async Task<ShareLinkRecord?> 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);
}
/// <summary>Runs cleanup for one record by id.</summary>
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<ShareLinkRecord> CleanupRecordInternalAsync(
ShareLinkRecord record,
IReadOnlyList<ShareLinkRecord> 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<string>();
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<ShareLinkRecord> 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);
}
}
+86
Voir le fichier
@@ -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;
/// <summary>Creates durable ShareLinks records and applies the temporary tag.</summary>
public sealed class ShareLinkCreationService
{
private readonly ShareLinkStore _store;
private readonly ShareTokenService _tokenService;
private readonly ItemTagService _itemTagService;
private readonly ILogger<ShareLinkCreationService> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinkCreationService"/> class.</summary>
public ShareLinkCreationService(
ShareLinkStore store,
ShareTokenService tokenService,
ItemTagService itemTagService,
ILogger<ShareLinkCreationService> logger)
{
_store = store;
_tokenService = tokenService;
_itemTagService = itemTagService;
_logger = logger;
}
/// <summary>Creates a new share-link record and returns the raw token once.</summary>
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;
}
}
}
+269
Voir le fichier
@@ -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;
/// <summary>Handles public share-link redemption and the bootstrap HTML response.</summary>
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<ShareLinkRedemptionService> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary>
public ShareLinkRedemptionService(
ILibraryManager libraryManager,
ShareLinkStore store,
ShareTokenService tokenService,
ItemTagService itemTagService,
JellyfinGuestUserService guestUserService,
ShareLinkCleanupService cleanupService,
ILogger<ShareLinkRedemptionService> logger)
{
_libraryManager = libraryManager;
_store = store;
_tokenService = tokenService;
_itemTagService = itemTagService;
_guestUserService = guestUserService;
_cleanupService = cleanupService;
_logger = logger;
}
/// <summary>Redeems a token and returns the bootstrap HTML, or null if the token is unusable.</summary>
public async Task<string?> 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<string> 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 $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signing in...</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; }
main { max-width: 36rem; padding: 2rem; }
.muted { color: #9ca3af; }
</style>
</head>
<body>
<main>
<div>Signing you in...</div>
<div class="muted" id="status">Preparing temporary access.</div>
</main>
<script>
(async () => {
const authUrl = {{authUrlJson}};
const redirectUrl = {{redirectUrlJson}};
const username = {{usernameJson}};
const deviceId = {{deviceIdJson}} || crypto.randomUUID().replace(/-/g, "");
document.getElementById("status").textContent = "Authenticating " + username + ".";
const response = await fetch(authUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Emby-Authorization": `MediaBrowser Client="ShareLinks", Device="ShareLinks", DeviceId="${deviceId}", Version="1.0.0"`
},
body: {{authJson}}
});
if (!response.ok) {
throw new Error(`Authentication failed (${response.status})`);
}
const auth = await response.json();
const accessToken = auth.AccessToken ?? auth.accessToken ?? "";
const userId = auth.User?.Id ?? auth.user?.Id ?? auth.UserId ?? auth.userId ?? "";
const userName = auth.User?.Name ?? auth.user?.Name ?? auth.UserName ?? auth.userName ?? username;
const snapshot = {
AccessToken: accessToken,
UserId: userId,
UserName: userName,
ServerUrl: window.location.origin
};
try {
for (const key of ["jellyfinCredentials", "jellyfin_credentials", "jellyfin-credentials"]) {
localStorage.setItem(key, JSON.stringify(snapshot));
}
localStorage.setItem("jellyfin.server", window.location.origin);
} catch (_) {
// Best effort only. Jellyfin Web storage format should be verified live.
}
window.location.replace(redirectUrl);
})().catch((error) => {
console.error(error);
document.getElementById("status").textContent = "Sign-in failed.";
});
</script>
</body>
</html>
""";
}
}
+214
Voir le fichier
@@ -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;
/// <summary>
/// Generates raw share tokens and their persisted HMAC hashes.
/// </summary>
public sealed class ShareTokenService
{
private readonly string _secretPath;
private readonly ILogger<ShareTokenService> _logger;
private readonly SemaphoreSlim _secretGate = new(1, 1);
private byte[]? _secretKey;
/// <summary>Initializes a new instance of the <see cref="ShareTokenService"/> class.</summary>
public ShareTokenService(IApplicationPaths applicationPaths, ILogger<ShareTokenService> logger)
{
_secretPath = Path.Combine(applicationPaths.DataPath, "sharelinks", "token-secret.key");
_logger = logger;
}
/// <summary>Creates a new 256-bit token and its HMAC hash.</summary>
public async Task<ShareTokenMaterial> 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
};
}
/// <summary>Computes the stored hash for a presented token.</summary>
public async Task<string> 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);
}
/// <summary>Validates a token against an expected hash.</summary>
public async Task<bool> 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;
}
}
/// <summary>Encrypts sensitive text using the shared plugin secret.</summary>
public async Task<string> 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);
}
/// <summary>Decrypts a sensitive string protected by <see cref="ProtectStringAsync"/>.</summary>
public async Task<string> 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<byte[]> 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<byte> tokenBytes)
{
using var hmac = new HMACSHA256(secret);
return Base64UrlEncode(hmac.ComputeHash(tokenBytes.ToArray()));
}
private static string Base64UrlEncode(ReadOnlySpan<byte> 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);
}
}
+216
Voir le fichier
@@ -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;
/// <summary>
/// JSON-backed persistent store for share-link records.
/// </summary>
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<ShareLinkStore> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Initializes a new instance of the <see cref="ShareLinkStore"/> class.</summary>
public ShareLinkStore(IApplicationPaths applicationPaths, ILogger<ShareLinkStore> logger)
{
_directory = Path.Combine(applicationPaths.DataPath, "sharelinks");
_path = Path.Combine(_directory, "sharelinks.json");
_logger = logger;
}
/// <summary>Lists all persisted share links.</summary>
public async Task<IReadOnlyList<ShareLinkRecord>> ListAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await LoadUnlockedAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
/// <summary>Gets a share link by token hash.</summary>
public async Task<ShareLinkRecord?> 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();
}
}
/// <summary>Gets a share link by id.</summary>
public async Task<ShareLinkRecord?> 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();
}
}
/// <summary>Inserts or replaces a share-link record.</summary>
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();
}
}
/// <summary>Updates an existing share-link record.</summary>
public async Task UpdateAsync(ShareLinkRecord record, CancellationToken cancellationToken = default)
{
await UpsertAsync(record, cancellationToken).ConfigureAwait(false);
}
/// <summary>Deletes a share-link record by id.</summary>
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<List<ShareLinkRecord>> LoadUnlockedAsync(CancellationToken cancellationToken)
{
try
{
if (!File.Exists(_path))
{
return new List<ShareLinkRecord>();
}
await using var stream = new FileStream(
_path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
return (await JsonSerializer.DeserializeAsync<List<ShareLinkRecord>>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false)) ?? new List<ShareLinkRecord>();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: could not read the record store.");
return new List<ShareLinkRecord>();
}
}
private async Task SortAndSaveUnlockedAsync(List<ShareLinkRecord> 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;
}
}
}
+60
Voir le fichier
@@ -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;
/// <summary>
/// Scheduled cleanup shell for later link-expiry and guest-account teardown work.
/// </summary>
public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurableScheduledTask
{
private readonly IShareLinkCleanupService _cleanupService;
/// <summary>Initializes a new instance of the <see cref="CleanupShareLinksScheduledTask"/> class.</summary>
public CleanupShareLinksScheduledTask(IShareLinkCleanupService cleanupService)
{
_cleanupService = cleanupService;
}
/// <inheritdoc />
public string Name => "Clean up ShareLinks";
/// <inheritdoc />
public string Key => "ShareLinksCleanup";
/// <inheritdoc />
public string Description => "Removes expired share links and performs future guest-account cleanup.";
/// <inheritdoc />
public string Category => "ShareLinks";
/// <inheritdoc />
public bool IsHidden => false;
/// <inheritdoc />
public bool IsEnabled => true;
/// <inheritdoc />
public bool IsLogged => true;
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
_ = progress;
await _cleanupService.CleanupAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
yield return new TaskTriggerInfo
{
Type = TaskTriggerInfoType.DailyTrigger,
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks,
};
}
}
+87
Voir le fichier
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class WebInjectionHostedService : IHostedService
{
private const string Begin = "<!-- ShareLinks:begin -->";
private const string End = "<!-- ShareLinks:end -->";
private readonly IServerApplicationPaths _paths;
private readonly ILogger<WebInjectionHostedService> _logger;
/// <summary>Initializes a new instance of the <see cref="WebInjectionHostedService"/> class.</summary>
public WebInjectionHostedService(IServerApplicationPaths paths, ILogger<WebInjectionHostedService> logger)
{
_paths = paths;
_logger = logger;
}
private string IndexPath => Path.Combine(_paths.WebPath, "index.html");
/// <inheritdoc />
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;
}
/// <inheritdoc />
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<script src=\"/ShareLinks/ClientScript\" defer></script>\n" + End + "\n";
var bodyIndex = html.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
html = bodyIndex >= 0 ? html.Insert(bodyIndex, snippet) : html + snippet;
File.WriteAllText(path, html);
_logger.LogInformation("ShareLinks: injected client script into {Path}.", path);
}
}
+359
Voir le fichier
@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html>
<head>
<title>ShareLinks</title>
<style>
#ShareLinksConfigPage .sl-section {
margin-bottom: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(127, 127, 127, 0.18);
}
#ShareLinksConfigPage .sl-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
}
#ShareLinksConfigPage .sl-field .fieldDescription {
margin-top: 0.25rem;
}
#ShareLinksConfigPage .sl-muted {
color: var(--theme-secondary-color, #666);
}
#ShareLinksConfigPage .sl-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
#ShareLinksConfigPage .sl-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
#ShareLinksConfigPage .sl-table th,
#ShareLinksConfigPage .sl-table td {
padding: 0.45rem 0.4rem;
border-bottom: 1px solid rgba(127, 127, 127, 0.14);
vertical-align: top;
text-align: left;
word-break: break-word;
}
#ShareLinksConfigPage .sl-table th {
font-size: 0.88rem;
font-weight: 600;
}
#ShareLinksConfigPage .sl-table .sl-right {
text-align: right;
white-space: nowrap;
}
#ShareLinksConfigPage .sl-inline {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
#ShareLinksConfigPage .sl-list-status {
min-height: 1.25rem;
}
@media (max-width: 900px) {
#ShareLinksConfigPage .sl-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div id="ShareLinksConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="ShareLinksConfigForm">
<div class="sl-section">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">ShareLinks</h2>
</div>
<p class="fieldDescription">
Creates short-lived guest links for movies and episodes, then keeps the active records visible for revocation.
</p>
</div>
<div class="sl-section">
<h3 class="sectionTitle">General</h3>
<div class="sl-grid">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="Enabled" />
<span>Enable ShareLinks</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="OneUseDefault" />
<span>Default one-use links</span>
</label>
</div>
<div class="sl-field inputContainer">
<input is="emby-input" type="number" id="DefaultExpiryHours" label="Default expiry (hours)" min="1" max="8760" />
<div class="fieldDescription">Used by the menu action when the admin accepts the default.</div>
</div>
<div class="sl-field inputContainer">
<input is="emby-input" type="number" id="MaxExpiryHours" label="Maximum expiry (hours)" min="1" max="8760" />
</div>
<div class="sl-field inputContainer">
<input is="emby-input" type="text" id="PublicBaseUrlOverride" label="Public base URL override" />
<div class="fieldDescription">Leave empty to derive the public URL from the current request.</div>
</div>
<div class="sl-field inputContainer">
<input is="emby-input" type="text" id="GuestUsernamePrefix" label="Guest username prefix" />
</div>
<div class="sl-field inputContainer">
<input is="emby-input" type="number" id="CleanupIntervalMinutes" label="Cleanup interval (minutes)" min="5" max="10080" />
</div>
</div>
</div>
<div class="sl-section">
<h3 class="sectionTitle">Playback policy</h3>
<div class="sl-grid">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="AllowTranscoding" />
<span>Allow transcoding</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="AllowRemuxing" />
<span>Allow remuxing</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="GuestModeLockdownEnabled" />
<span>Guest lockdown</span>
</label>
<div class="fieldDescription">Hides primary navigation for guest sessions in the web client. This is UX only, not the security boundary.</div>
</div>
</div>
</div>
<div class="sl-section">
<div class="sl-inline">
<h3 class="sectionTitle">Share links</h3>
<button is="emby-button" type="button" id="RefreshLinks" class="raised">
<span>Refresh</span>
</button>
<span id="LinksStatus" class="sl-muted sl-list-status">Loading…</span>
</div>
<div style="overflow-x:auto; margin-top:0.5rem;">
<table class="sl-table">
<thead>
<tr>
<th style="width: 9rem;">Status</th>
<th>Item</th>
<th style="width: 11rem;">Guest</th>
<th style="width: 11rem;">Expires</th>
<th style="width: 9rem;" class="sl-right">Actions</th>
</tr>
</thead>
<tbody id="LinksBody">
<tr>
<td colspan="5" class="sl-muted">No links loaded yet.</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
(function () {
var ShareLinksPluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
var page;
function loadConfig() {
Dashboard.showLoadingMsg();
return ApiClient.getPluginConfiguration(ShareLinksPluginId).then(function (cfg) {
page.querySelector('#Enabled').checked = cfg.Enabled !== false;
page.querySelector('#DefaultExpiryHours').value = cfg.DefaultExpiryHours || 24;
page.querySelector('#MaxExpiryHours').value = cfg.MaxExpiryHours || 720;
page.querySelector('#PublicBaseUrlOverride').value = cfg.PublicBaseUrlOverride || '';
page.querySelector('#GuestUsernamePrefix').value = cfg.GuestUsernamePrefix || 'share-';
page.querySelector('#AllowTranscoding').checked = cfg.AllowTranscoding !== false;
page.querySelector('#AllowRemuxing').checked = cfg.AllowRemuxing !== false;
page.querySelector('#CleanupIntervalMinutes').value = cfg.CleanupIntervalMinutes || 60;
page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false;
page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false;
}).finally(function () {
Dashboard.hideLoadingMsg();
});
}
function fmtDate(value) {
if (!value) { return 'n/a'; }
return new Date(value).toLocaleString();
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderLinks(list) {
var body = page.querySelector('#LinksBody');
var items = Array.isArray(list) ? list.slice().sort(function (a, b) {
return new Date(b.CreatedAtUtc || b.CreatedAt || 0) - new Date(a.CreatedAtUtc || a.CreatedAt || 0);
}) : [];
if (!items.length) {
body.innerHTML = '<tr><td colspan="5" class="sl-muted">No active links.</td></tr>';
page.querySelector('#LinksStatus').textContent = 'No share links.';
return;
}
body.innerHTML = items.map(function (record) {
var status = record.Status || 'Unknown';
var itemName = escapeHtml(record.ItemNameSnapshot || record.ItemId || '');
var guestName = escapeHtml(record.GuestUserName || 'n/a');
var expires = fmtDate(record.ExpiresAtUtc || record.ExpiresAt);
return [
'<tr>',
'<td>', escapeHtml(status), '</td>',
'<td>',
'<div><strong>', itemName, '</strong></div>',
'<div class="sl-muted">', escapeHtml(record.ItemId || ''), '</div>',
'</td>',
'<td>', guestName, '</td>',
'<td>', escapeHtml(expires), '</td>',
'<td class="sl-right">',
'<button is="emby-button" type="button" class="raised" data-id="', escapeHtml(record.Id), '">',
'<span>Revoke</span>',
'</button>',
'</td>',
'</tr>'
].join('');
}).join('');
Array.from(body.querySelectorAll('button[data-id]')).forEach(function (button) {
button.addEventListener('click', function () {
revokeLink(button.getAttribute('data-id'));
});
});
page.querySelector('#LinksStatus').textContent = items.length + ' link' + (items.length === 1 ? '' : 's') + ' loaded.';
}
function loadLinks() {
var status = page.querySelector('#LinksStatus');
status.textContent = 'Loading share links…';
return ApiClient.ajax({
type: 'GET',
url: ApiClient.getUrl('ShareLinks/Admin/List'),
dataType: 'json'
}).then(function (list) {
renderLinks(list || []);
}).catch(function (error) {
status.textContent = 'Could not load share links.';
page.querySelector('#LinksBody').innerHTML = '<tr><td colspan="5" class="sl-muted">' + escapeHtml(error && error.message ? error.message : 'Load failed.') + '</td></tr>';
});
}
function revokeLink(id) {
if (!id) {
return;
}
var button = page.querySelector('button[data-id="' + id + '"]');
if (button && button.getAttribute('data-confirm') !== '1') {
button.setAttribute('data-confirm', '1');
button.querySelector('span').textContent = 'Confirm revoke';
window.setTimeout(function () {
if (button.isConnected && button.getAttribute('data-confirm') === '1') {
button.removeAttribute('data-confirm');
button.querySelector('span').textContent = 'Revoke';
}
}, 3500);
return;
}
Dashboard.showLoadingMsg();
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('ShareLinks/Admin/Revoke/' + id),
dataType: 'json'
}).then(function () {
return loadLinks();
}).finally(function () {
Dashboard.hideLoadingMsg();
});
}
function save(e) {
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(ShareLinksPluginId).then(function (cfg) {
cfg.Enabled = page.querySelector('#Enabled').checked;
cfg.DefaultExpiryHours = parseInt(page.querySelector('#DefaultExpiryHours').value, 10) || 24;
cfg.MaxExpiryHours = parseInt(page.querySelector('#MaxExpiryHours').value, 10) || 720;
cfg.PublicBaseUrlOverride = page.querySelector('#PublicBaseUrlOverride').value.trim();
cfg.GuestUsernamePrefix = page.querySelector('#GuestUsernamePrefix').value.trim() || 'share-';
cfg.AllowTranscoding = page.querySelector('#AllowTranscoding').checked;
cfg.AllowRemuxing = page.querySelector('#AllowRemuxing').checked;
cfg.CleanupIntervalMinutes = parseInt(page.querySelector('#CleanupIntervalMinutes').value, 10) || 60;
cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked;
cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked;
ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
return loadLinks();
}).finally(function () {
Dashboard.hideLoadingMsg();
});
});
return false;
}
document.querySelector('#ShareLinksConfigPage').addEventListener('pageshow', function () {
page = this;
loadConfig().then(loadLinks);
});
document.querySelector('#ShareLinksConfigForm').addEventListener('submit', save);
document.querySelector('#RefreshLinks').addEventListener('click', function () {
loadLinks();
});
})();
</script>
</div>
</body>
</html>
+877
Voir le fichier
@@ -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();
})();
+12
Voir le fichier
@@ -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"
}
+85
Voir le fichier
@@ -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.
+19
Voir le fichier
@@ -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
+21
Voir le fichier
@@ -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"
}
]
}
]