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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# ShareLinks
|
||||||
|
|
||||||
|
ShareLinks is a Jellyfin 10.11 plugin scaffold for issuing expiring guest-share
|
||||||
|
links to individual items. This staging tree now includes the core dashboard
|
||||||
|
and web-client wiring that later workers will build on:
|
||||||
|
|
||||||
|
- plugin metadata and DI registration
|
||||||
|
- JSON-backed record storage
|
||||||
|
- token generation and hashing
|
||||||
|
- startup and scheduled cleanup shells
|
||||||
|
- configuration defaults
|
||||||
|
- Jellyfin Web script injection and a dashboard config page shell
|
||||||
|
|
||||||
|
## Current security stance
|
||||||
|
|
||||||
|
The design goal is simple: a raw share token should exist only at the moment it
|
||||||
|
is issued, returned to the caller once, and then forgotten. Persistent storage
|
||||||
|
keeps only a keyed HMAC hash of the token plus the metadata needed to audit or
|
||||||
|
clean up the link.
|
||||||
|
|
||||||
|
That means later API work must keep a few rules:
|
||||||
|
|
||||||
|
1. never log raw tokens
|
||||||
|
2. never write raw tokens to disk
|
||||||
|
3. only return the token in the initial creation response
|
||||||
|
4. treat token validation as hash comparison only
|
||||||
|
5. keep guest-user creation and teardown behind explicit service calls
|
||||||
|
|
||||||
|
## Configuration plan
|
||||||
|
|
||||||
|
The `PluginConfiguration` defaults are deliberately opinionated:
|
||||||
|
|
||||||
|
- default expiry hours
|
||||||
|
- maximum allowed expiry hours
|
||||||
|
- optional public base URL override
|
||||||
|
- guest username prefix
|
||||||
|
- transcoding and remuxing toggles
|
||||||
|
- cleanup interval in minutes
|
||||||
|
- one-use default
|
||||||
|
- guest-mode lockdown enabled by default
|
||||||
|
|
||||||
|
Later workers should wire those settings into the issue / redeem / revoke
|
||||||
|
pipeline and into the guest-user creation logic.
|
||||||
|
|
||||||
|
## Storage layout
|
||||||
|
|
||||||
|
The staging implementation stores plugin data under Jellyfin's application data
|
||||||
|
path, in a dedicated `sharelinks` directory. The persistent JSON store keeps
|
||||||
|
`ShareLinkRecord` entries keyed by id, while the token service stores its secret
|
||||||
|
key separately in the same directory.
|
||||||
|
|
||||||
|
This keeps the plugin portable and avoids any hardcoded filesystem locations.
|
||||||
|
|
||||||
|
## Cleanup architecture
|
||||||
|
|
||||||
|
There are two cleanup entry points already wired:
|
||||||
|
|
||||||
|
- `Tasks/CleanupShareLinksScheduledTask.cs`
|
||||||
|
- `Lifecycle/StartupCleanupHostedService.cs`
|
||||||
|
|
||||||
|
Both currently call an `IShareLinkCleanupService` implementation that is a
|
||||||
|
no-op. That gives later workers a stable seam for:
|
||||||
|
|
||||||
|
- expiring old links
|
||||||
|
- removing one-use links after redemption
|
||||||
|
- tearing down guest accounts and tokens
|
||||||
|
- recording cleanup attempts and failures
|
||||||
|
|
||||||
|
## Endpoint audit requirements
|
||||||
|
|
||||||
|
When the API surface is added, it should be audited for:
|
||||||
|
|
||||||
|
- authz on every create/list/redeem/revoke endpoint
|
||||||
|
- exact token handling on create and redeem flows
|
||||||
|
- rate limiting for token guesses and redemption retries
|
||||||
|
- whether any response leaks the token hash, raw token, or guest credentials
|
||||||
|
- whether guest-mode lockdown is enforced consistently
|
||||||
|
- whether cleanup can safely run while links are being created or redeemed
|
||||||
|
- whether error messages reveal link existence or status
|
||||||
|
|
||||||
|
## What is intentionally missing
|
||||||
|
|
||||||
|
The remaining work is mostly policy polish and cleanup edge cases. The plugin
|
||||||
|
already has its controller, web injection hook, and dashboard page entry point
|
||||||
|
in place.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ShareLinks", "Jellyfin.Plugin.ShareLinks\Jellyfin.Plugin.ShareLinks.csproj", "{03D33F07-50D3-4CE8-876F-63638614B25C}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{03D33F07-50D3-4CE8-876F-63638614B25C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{03D33F07-50D3-4CE8-876F-63638614B25C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{03D33F07-50D3-4CE8-876F-63638614B25C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{03D33F07-50D3-4CE8-876F-63638614B25C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
|
||||||
|
"name": "ShareLinks",
|
||||||
|
"description": "Adds secure, expiring guest-share links for Jellyfin items using temporary hidden users, one-use token redemption, restrictive policies, and scheduled cleanup.",
|
||||||
|
"overview": "Temporary guest links for Jellyfin movies and episodes.",
|
||||||
|
"owner": "Franciskid",
|
||||||
|
"category": "General",
|
||||||
|
"imageUrl": "",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.0.0",
|
||||||
|
"changelog": "Initial hybrid ShareLinks build with admin web menu integration, token redemption, temporary guest users, and cleanup.",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "",
|
||||||
|
"checksum": "",
|
||||||
|
"timestamp": "2026-07-06T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Référencer dans un nouveau ticket
Bloquer un utilisateur