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"
}