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 à :
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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"
|
||||
}
|
||||
Référencer dans un nouveau ticket
Bloquer un utilisateur