ShareLinks plugin: guest share links for Jellyfin

Includes fix for redemption failing with DbUpdateConcurrencyException:
change the guest password before applying the user policy, since
UpdatePolicyAsync bumps the user's EF concurrency token and a stale
instance then breaks ChangePassword.
Cette révision appartient à :
Franciskid
2026-07-06 18:21:22 +02:00
révision c29ea7f20c
26 fichiers modifiés avec 3804 ajouts et 0 suppressions
+13
Voir le fichier
@@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>
/// Cleanup seam for later workers. The initial implementation is a no-op.
/// </summary>
public interface IShareLinkCleanupService
{
/// <summary>Runs one cleanup pass.</summary>
Task CleanupAsync(CancellationToken cancellationToken);
}
+122
Voir le fichier
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>Applies and removes temporary tags on shared items.</summary>
public sealed class ItemTagService
{
private readonly ILibraryManager _libraryManager;
private readonly ILogger<ItemTagService> _logger;
/// <summary>Initializes a new instance of the <see cref="ItemTagService"/> class.</summary>
public ItemTagService(ILibraryManager libraryManager, ILogger<ItemTagService> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
/// <summary>Ensures the supplied tag is present on the item and persisted.</summary>
public async Task<bool> EnsureTagAsync(BaseItem item, string tag, CancellationToken cancellationToken)
{
if (item is null)
{
throw new ArgumentNullException(nameof(item));
}
if (string.IsNullOrWhiteSpace(tag))
{
throw new ArgumentException("Tag cannot be empty.", nameof(tag));
}
var tags = item.Tags?.ToList() ?? new List<string>();
if (tags.Any(existing => string.Equals(existing, tag, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
tags.Add(tag);
item.Tags = tags.ToArray();
await PersistAsync(item, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("ShareLinks: applied temporary tag {Tag} to item {ItemId}.", tag, item.Id);
return true;
}
/// <summary>Removes the supplied tag from the item and persists the change.</summary>
public async Task<bool> RemoveTagAsync(BaseItem item, string tag, CancellationToken cancellationToken)
{
if (item is null)
{
throw new ArgumentNullException(nameof(item));
}
if (string.IsNullOrWhiteSpace(tag))
{
throw new ArgumentException("Tag cannot be empty.", nameof(tag));
}
var tags = item.Tags?.ToList() ?? new List<string>();
var removed = tags.RemoveAll(existing => string.Equals(existing, tag, StringComparison.OrdinalIgnoreCase)) > 0;
if (!removed)
{
return false;
}
item.Tags = tags.ToArray();
await PersistAsync(item, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("ShareLinks: removed temporary tag {Tag} from item {ItemId}.", tag, item.Id);
return true;
}
private async Task PersistAsync(BaseItem item, CancellationToken cancellationToken)
{
var method = _libraryManager.GetType()
.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.FirstOrDefault(candidate =>
{
if (!string.Equals(candidate.Name, "UpdateItemAsync", StringComparison.Ordinal))
{
return false;
}
var parameters = candidate.GetParameters();
return parameters.Length == 4
&& typeof(BaseItem).IsAssignableFrom(parameters[0].ParameterType)
&& typeof(BaseItem).IsAssignableFrom(parameters[1].ParameterType)
&& parameters[3].ParameterType == typeof(CancellationToken);
});
if (method is null)
{
throw new MissingMethodException(_libraryManager.GetType().FullName, "UpdateItemAsync");
}
var parametersInfo = method.GetParameters();
var updateReason = parametersInfo[2].ParameterType.IsEnum
? Enum.ToObject(parametersInfo[2].ParameterType, 0)
: 0;
var parent = item.DisplayParent ?? item;
var task = method.Invoke(_libraryManager, new object?[]
{
item,
parent,
updateReason,
cancellationToken
}) as Task;
if (task is null)
{
throw new InvalidOperationException("UpdateItemAsync did not return a task.");
}
await task.ConfigureAwait(false);
}
}
+514
Voir le fichier
@@ -0,0 +1,514 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Configuration;
using Jellyfin.Plugin.ShareLinks.Models;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Users;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>Creates and tears down temporary Jellyfin guest users.</summary>
public sealed class JellyfinGuestUserService
{
private readonly IUserManager _userManager;
private readonly ILogger<JellyfinGuestUserService> _logger;
/// <summary>Initializes a new instance of the <see cref="JellyfinGuestUserService"/> class.</summary>
public JellyfinGuestUserService(IUserManager userManager, ILogger<JellyfinGuestUserService> logger)
{
_userManager = userManager;
_logger = logger;
}
/// <summary>Builds the temporary guest username for a share record.</summary>
public static string BuildGuestUsername(ShareLinkRecord record)
{
var prefix = Plugin.Instance?.Configuration.GuestUsernamePrefix ?? "share-";
return $"{prefix}{record.Id:N}";
}
/// <summary>Generates a strong random password suitable for a temporary guest user.</summary>
public static string GeneratePassword()
{
var bytes = new byte[32];
RandomNumberGenerator.Fill(bytes);
return Base64UrlEncode(bytes);
}
/// <summary>Ensures the temporary guest user exists and has the correct policy and password.</summary>
public async Task<dynamic> EnsureGuestUserAsync(ShareLinkRecord record, string password, CancellationToken cancellationToken)
{
if (record is null)
{
throw new ArgumentNullException(nameof(record));
}
if (string.IsNullOrWhiteSpace(password))
{
throw new ArgumentException("Password cannot be empty.", nameof(password));
}
var username = record.GuestUserName;
if (string.IsNullOrWhiteSpace(username))
{
username = BuildGuestUsername(record);
record.GuestUserName = username;
}
object? user = _userManager.GetUserByName(username);
if (user is null)
{
user = await InvokeUserManagerAsync<object?>(
"create user",
cancellationToken,
new InvocationCandidate("CreateUserAsync", new object?[] { username }),
new InvocationCandidate("CreateUser", new object?[] { username }))
.ConfigureAwait(false) ?? _userManager.GetUserByName(username);
if (user is null)
{
throw new InvalidOperationException($"Unable to create temporary guest user '{username}'.");
}
}
var existingUserId = GetUserId(user);
if (existingUserId != Guid.Empty)
{
user = _userManager.GetUserById(existingUserId) ?? user;
}
// The password must be changed before the policy update: UpdatePolicyAsync bumps the
// user's EF concurrency token server-side, and ChangePassword with a stale instance
// throws DbUpdateConcurrencyException.
await ChangePasswordAsync(user, password, cancellationToken).ConfigureAwait(false);
await ApplyPolicyAsync(user, record, disabled: false, cancellationToken).ConfigureAwait(false);
var userId = GetUserId(user);
if (userId == Guid.Empty)
{
throw new InvalidOperationException("ShareLinks: created guest user did not expose a valid Id.");
}
user = _userManager.GetUserById(userId) ?? user;
_logger.LogInformation("ShareLinks: ensured guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id);
return user;
}
/// <summary>Disables a temporary guest user before deletion.</summary>
public async Task DisableGuestUserAsync(ShareLinkRecord record, CancellationToken cancellationToken)
{
var user = FindRecordUser(record);
if (user is null)
{
return;
}
try
{
await ApplyPolicyAsync(user, record, disabled: true, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: failed to disable guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id);
}
}
/// <summary>Deletes a temporary guest user if it exists.</summary>
public async Task DeleteGuestUserAsync(ShareLinkRecord record, CancellationToken cancellationToken)
{
var user = FindRecordUser(record);
if (user is null)
{
return;
}
try
{
await DeleteUserAsync(user, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: failed to delete guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id);
throw;
}
}
private object? FindRecordUser(ShareLinkRecord record)
{
if (record.GuestUserId.HasValue)
{
var user = _userManager.GetUserById(record.GuestUserId.Value);
if (user is not null)
{
return user;
}
}
if (!string.IsNullOrWhiteSpace(record.GuestUserName))
{
return _userManager.GetUserByName(record.GuestUserName);
}
return null;
}
private async Task ApplyPolicyAsync(object user, ShareLinkRecord record, bool disabled, CancellationToken cancellationToken)
{
var config = Plugin.Instance!.Configuration;
var policy = new UserPolicy();
SetPolicyValue(policy, "AuthenticationProviderId", GetUserValue(user, "AuthenticationProviderId"));
SetPolicyValue(policy, "PasswordResetProviderId", GetUserValue(user, "PasswordResetProviderId"));
SetPolicyValue(policy, "AllowedTags", string.IsNullOrWhiteSpace(record.AllowedTag) ? Array.Empty<string>() : new[] { record.AllowedTag! });
SetPolicyValue(policy, "BlockedTags", Array.Empty<string>());
SetPolicyValue(policy, "IsAdministrator", false);
SetPolicyValue(policy, "IsHidden", true);
SetPolicyValue(policy, "IsDisabled", disabled);
SetPolicyValue(policy, "EnableCollectionManagement", false);
SetPolicyValue(policy, "EnableSubtitleManagement", false);
SetPolicyValue(policy, "EnableLyricManagement", false);
SetPolicyValue(policy, "EnableUserPreferenceAccess", false);
SetPolicyValue(policy, "EnableSharedDeviceControl", false);
SetPolicyValue(policy, "EnableRemoteAccess", true);
SetPolicyValue(policy, "EnableRemoteControlOfOtherUsers", false);
SetPolicyValue(policy, "EnableLiveTvManagement", false);
SetPolicyValue(policy, "EnableLiveTvAccess", false);
SetPolicyValue(policy, "EnableMediaPlayback", true);
SetPolicyValue(policy, "EnableAudioPlaybackTranscoding", config.AllowTranscoding);
SetPolicyValue(policy, "EnableVideoPlaybackTranscoding", config.AllowTranscoding);
SetPolicyValue(policy, "EnablePlaybackRemuxing", config.AllowRemuxing);
SetPolicyValue(policy, "ForceRemoteSourceTranscoding", false);
SetPolicyValue(policy, "EnableContentDeletion", false);
SetPolicyValue(policy, "EnableContentDeletionFromFolders", Array.Empty<string>());
SetPolicyValue(policy, "EnableContentDownloading", false);
SetPolicyValue(policy, "EnableSyncTranscoding", false);
SetPolicyValue(policy, "EnableMediaConversion", false);
SetPolicyValue(policy, "EnableAllChannels", false);
SetPolicyValue(policy, "EnabledChannels", Array.Empty<Guid>());
SetPolicyValue(policy, "EnableAllDevices", true);
SetPolicyValue(policy, "EnabledDevices", Array.Empty<string>());
SetPolicyValue(policy, "EnableAllFolders", true);
SetPolicyValue(policy, "EnabledFolders", Array.Empty<Guid>());
SetPolicyValue(policy, "EnablePublicSharing", false);
SetPolicyValue(policy, "LoginAttemptsBeforeLockout", -1);
SetPolicyValue(policy, "MaxActiveSessions", 1);
SetPolicyValue(policy, "BlockUnratedItems", Array.Empty<Jellyfin.Data.Enums.UnratedItem>());
await InvokeUserManagerAsync<object?>(
"update policy",
cancellationToken,
new InvocationCandidate("UpdatePolicyAsync", new object?[] { GetUserId(user), policy }),
new InvocationCandidate("UpdatePolicyAsync", new object?[] { user, policy }),
new InvocationCandidate("UpdatePolicy", new object?[] { GetUserId(user), policy }),
new InvocationCandidate("UpdatePolicy", new object?[] { user, policy }))
.ConfigureAwait(false);
}
private async Task ChangePasswordAsync(object user, string password, CancellationToken cancellationToken)
{
await InvokeUserManagerAsync<object?>(
"change password",
cancellationToken,
new InvocationCandidate("ChangePasswordAsync", new object?[] { user, password }),
new InvocationCandidate("ChangePasswordAsync", new object?[] { GetUserId(user), password }),
new InvocationCandidate("ChangePasswordAsync", new object?[] { user, string.Empty, password }),
new InvocationCandidate("ChangePasswordAsync", new object?[] { GetUserId(user), string.Empty, password }),
new InvocationCandidate("ChangePassword", new object?[] { user, password }),
new InvocationCandidate("ChangePassword", new object?[] { GetUserId(user), password }),
new InvocationCandidate("ChangePassword", new object?[] { user, string.Empty, password }),
new InvocationCandidate("ChangePassword", new object?[] { GetUserId(user), string.Empty, password }))
.ConfigureAwait(false);
}
private async Task DeleteUserAsync(object user, CancellationToken cancellationToken)
{
await InvokeUserManagerAsync<object?>(
"delete user",
cancellationToken,
new InvocationCandidate("DeleteUserAsync", new object?[] { GetUserId(user) }),
new InvocationCandidate("DeleteUserAsync", new object?[] { user }),
new InvocationCandidate("DeleteUser", new object?[] { GetUserId(user) }),
new InvocationCandidate("DeleteUser", new object?[] { user }))
.ConfigureAwait(false);
}
private async Task<T?> InvokeUserManagerAsync<T>(
string operationName,
CancellationToken cancellationToken,
params InvocationCandidate[] candidates)
{
var managerType = _userManager.GetType();
var triedVariants = new List<string>();
foreach (var candidate in candidates)
{
var methods = managerType.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.Where(method => string.Equals(method.Name, candidate.MethodName, StringComparison.Ordinal));
var matchedMethod = false;
foreach (var method in methods)
{
if (!TryBindArguments(method, candidate.Arguments, cancellationToken, out var invocationArguments))
{
continue;
}
matchedMethod = true;
var invocation = method.Invoke(_userManager, invocationArguments);
if (invocation is Task task)
{
await task.WaitAsync(cancellationToken).ConfigureAwait(false);
var resultProperty = invocation.GetType().GetProperty("Result", BindingFlags.Instance | BindingFlags.Public);
if (resultProperty is null)
{
return default;
}
var result = resultProperty.GetValue(invocation);
if (result is null)
{
return default;
}
if (result is T typedResult)
{
return typedResult;
}
throw new InvalidOperationException(
$"ShareLinks: {managerType.FullName}.{candidate.MethodName} returned incompatible result type {result.GetType().FullName} for {operationName}.");
}
if (invocation is T directResult)
{
return directResult;
}
if (invocation is null)
{
return default;
}
throw new InvalidOperationException(
$"ShareLinks: {managerType.FullName}.{candidate.MethodName} returned incompatible result type {invocation.GetType().FullName} for {operationName}.");
}
if (!matchedMethod)
{
triedVariants.Add($"{candidate.MethodName}({DescribeArguments(candidate.Arguments)})");
}
}
_logger.LogWarning(
"ShareLinks: {UserManagerType} does not expose a compatible {Operation} variant. Tried {Variants}.",
managerType.FullName,
operationName,
string.Join("; ", triedVariants));
throw new MissingMethodException(managerType.FullName, operationName);
}
private static string DescribeArguments(object?[] arguments)
{
return string.Join(", ", arguments.Select(argument => argument?.GetType().Name ?? "null"));
}
private static bool TryBindArguments(MethodInfo method, object?[] suppliedArguments, CancellationToken cancellationToken, out object?[] invocationArguments)
{
var parameters = method.GetParameters();
if (suppliedArguments.Length > parameters.Length)
{
invocationArguments = Array.Empty<object?>();
return false;
}
invocationArguments = new object?[parameters.Length];
for (var index = 0; index < suppliedArguments.Length; index++)
{
if (!TryConvertValue(parameters[index].ParameterType, suppliedArguments[index], out var convertedArgument))
{
invocationArguments = Array.Empty<object?>();
return false;
}
invocationArguments[index] = convertedArgument;
}
for (var index = suppliedArguments.Length; index < parameters.Length; index++)
{
var parameter = parameters[index];
if (parameter.ParameterType == typeof(CancellationToken))
{
invocationArguments[index] = cancellationToken;
continue;
}
if (parameter.IsOptional)
{
invocationArguments[index] = GetOptionalParameterValue(parameter);
continue;
}
invocationArguments = Array.Empty<object?>();
return false;
}
return true;
}
private static object? GetOptionalParameterValue(ParameterInfo parameter)
{
var defaultValue = parameter.DefaultValue;
if (defaultValue is not null && defaultValue != DBNull.Value && defaultValue != Type.Missing)
{
return defaultValue;
}
return parameter.ParameterType.IsValueType
? Activator.CreateInstance(parameter.ParameterType)
: null;
}
private static void SetPolicyValue(UserPolicy policy, string memberName, object? value)
{
var policyType = policy.GetType();
var property = policyType.GetProperty(memberName, BindingFlags.Instance | BindingFlags.Public);
if (property is not null && property.CanWrite && TryConvertValue(property.PropertyType, value, out var convertedPropertyValue))
{
property.SetValue(policy, convertedPropertyValue);
return;
}
var field = policyType.GetField(memberName, BindingFlags.Instance | BindingFlags.Public);
if (field is not null && TryConvertValue(field.FieldType, value, out var convertedFieldValue))
{
field.SetValue(policy, convertedFieldValue);
}
}
private static Guid GetUserId(object user)
{
var value = GetUserValue(user, "Id");
if (value is Guid guid)
{
return guid;
}
if (value is string text && Guid.TryParse(text, out var parsedGuid))
{
return parsedGuid;
}
return Guid.Empty;
}
private static string GetUserName(object user)
{
var value = GetUserValue(user, "Username");
if (value is string username && !string.IsNullOrWhiteSpace(username))
{
return username;
}
value = GetUserValue(user, "Name");
return value as string ?? string.Empty;
}
private static object? GetUserValue(object user, string propertyName)
{
var property = user.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
return property?.GetValue(user);
}
private static bool TryConvertValue(Type targetType, object? value, out object? converted)
{
if (targetType.IsByRef)
{
targetType = targetType.GetElementType() ?? targetType;
}
var nonNullableType = Nullable.GetUnderlyingType(targetType) ?? targetType;
if (value is null)
{
converted = null;
return !nonNullableType.IsValueType || Nullable.GetUnderlyingType(targetType) is not null;
}
if (nonNullableType.IsInstanceOfType(value) || targetType.IsAssignableFrom(value.GetType()))
{
converted = value;
return true;
}
if (nonNullableType.IsEnum)
{
if (value is string text)
{
converted = Enum.Parse(nonNullableType, text, ignoreCase: true);
return true;
}
if (IsNumeric(value))
{
converted = Enum.ToObject(nonNullableType, value);
return true;
}
}
if (nonNullableType == typeof(Guid) && value is string guidText && Guid.TryParse(guidText, out var guid))
{
converted = guid;
return true;
}
if (value is IConvertible)
{
try
{
converted = Convert.ChangeType(value, nonNullableType, CultureInfo.InvariantCulture);
return true;
}
catch
{
// The caller will ignore the missing or incompatible policy member.
}
}
converted = null;
return false;
}
private static bool IsNumeric(object value)
{
return value is byte
or sbyte
or short
or ushort
or int
or uint
or long
or ulong
or float
or double
or decimal;
}
private sealed record InvocationCandidate(string MethodName, object?[] Arguments);
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
+17
Voir le fichier
@@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>
/// Temporary cleanup implementation used until the real cleanup pipeline lands.
/// </summary>
public sealed class NoOpShareLinkCleanupService : IShareLinkCleanupService
{
/// <inheritdoc />
public Task CleanupAsync(CancellationToken cancellationToken)
{
_ = cancellationToken;
return Task.CompletedTask;
}
}
+173
Voir le fichier
@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Models;
using Jellyfin.Plugin.ShareLinks.Storage;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>Cleanly expires links and tears down temporary guest state.</summary>
public sealed class ShareLinkCleanupService : IShareLinkCleanupService
{
private readonly ShareLinkStore _store;
private readonly ILibraryManager _libraryManager;
private readonly ItemTagService _itemTagService;
private readonly JellyfinGuestUserService _guestUserService;
private readonly ILogger<ShareLinkCleanupService> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinkCleanupService"/> class.</summary>
public ShareLinkCleanupService(
ShareLinkStore store,
ILibraryManager libraryManager,
ItemTagService itemTagService,
JellyfinGuestUserService guestUserService,
ILogger<ShareLinkCleanupService> logger)
{
_store = store;
_libraryManager = libraryManager;
_itemTagService = itemTagService;
_guestUserService = guestUserService;
_logger = logger;
}
/// <inheritdoc />
public async Task CleanupAsync(CancellationToken cancellationToken)
{
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
foreach (var record in records)
{
await CleanupRecordInternalAsync(record, records, false, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>Revokes a specific share link and immediately runs teardown.</summary>
public async Task<ShareLinkRecord?> RevokeAsync(Guid id, CancellationToken cancellationToken)
{
var record = await _store.GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
record.Status = ShareLinkStatus.Revoked;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
return await CleanupRecordInternalAsync(record, records, true, cancellationToken).ConfigureAwait(false);
}
/// <summary>Runs cleanup for one record by id.</summary>
public async Task CleanupRecordAsync(Guid id, bool force, CancellationToken cancellationToken)
{
var record = await _store.GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return;
}
var records = await _store.ListAsync(cancellationToken).ConfigureAwait(false);
await CleanupRecordInternalAsync(record, records, force, cancellationToken).ConfigureAwait(false);
}
private async Task<ShareLinkRecord> CleanupRecordInternalAsync(
ShareLinkRecord record,
IReadOnlyList<ShareLinkRecord> allRecords,
bool force,
CancellationToken cancellationToken)
{
record.CleanupAttempts += 1;
var now = DateTimeOffset.UtcNow;
var shouldExpire = record.ExpiresAtUtc <= now && record.Status is not ShareLinkStatus.Expired and not ShareLinkStatus.Revoked;
if (shouldExpire)
{
record.Status = ShareLinkStatus.Expired;
}
var shouldTeardown = force
|| record.Status is ShareLinkStatus.Expired
|| record.Status is ShareLinkStatus.Revoked
|| record.Status is ShareLinkStatus.Failed;
if (!shouldTeardown)
{
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
return record;
}
var errors = new List<string>();
try
{
await _guestUserService.DisableGuestUserAsync(record, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
errors.Add($"disable:{ex.Message}");
_logger.LogWarning(ex, "ShareLinks: failed to disable guest user for record {RecordId}.", record.Id);
}
try
{
await _guestUserService.DeleteGuestUserAsync(record, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
errors.Add($"delete:{ex.Message}");
_logger.LogWarning(ex, "ShareLinks: failed to delete guest user for record {RecordId}.", record.Id);
}
if (!string.IsNullOrWhiteSpace(record.AllowedTag) && !IsTagStillInUse(record, allRecords, now))
{
var item = TryGetItem(record.ItemId);
if (item is not null)
{
try
{
var removed = await _itemTagService.RemoveTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false);
record.MetadataTouched |= removed;
}
catch (Exception ex)
{
errors.Add($"tag:{ex.Message}");
_logger.LogWarning(ex, "ShareLinks: failed to remove tag {Tag} from record {RecordId}.", record.AllowedTag, record.Id);
}
}
}
record.CleanupError = errors.Count == 0 ? null : string.Join(" | ", errors);
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
return record;
}
private BaseItem? TryGetItem(string itemId)
{
if (!Guid.TryParse(itemId, out var id))
{
return null;
}
return _libraryManager.GetItemById(id);
}
private static bool IsTagStillInUse(ShareLinkRecord record, IReadOnlyList<ShareLinkRecord> allRecords, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(record.AllowedTag))
{
return false;
}
return allRecords.Any(other =>
other.Id != record.Id
&& string.Equals(other.AllowedTag, record.AllowedTag, StringComparison.OrdinalIgnoreCase)
&& other.ExpiresAtUtc > now
&& other.Status is ShareLinkStatus.Pending
or ShareLinkStatus.Active
or ShareLinkStatus.Redeeming
or ShareLinkStatus.Redeemed);
}
}
+86
Voir le fichier
@@ -0,0 +1,86 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Models;
using Jellyfin.Plugin.ShareLinks.Storage;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>Creates durable ShareLinks records and applies the temporary tag.</summary>
public sealed class ShareLinkCreationService
{
private readonly ShareLinkStore _store;
private readonly ShareTokenService _tokenService;
private readonly ItemTagService _itemTagService;
private readonly ILogger<ShareLinkCreationService> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinkCreationService"/> class.</summary>
public ShareLinkCreationService(
ShareLinkStore store,
ShareTokenService tokenService,
ItemTagService itemTagService,
ILogger<ShareLinkCreationService> logger)
{
_store = store;
_tokenService = tokenService;
_itemTagService = itemTagService;
_logger = logger;
}
/// <summary>Creates a new share-link record and returns the raw token once.</summary>
public async Task<(ShareLinkRecord Record, string RawToken)> CreateAsync(
BaseItem item,
Guid createdByUserId,
int expiryHours,
bool oneUse,
CancellationToken cancellationToken)
{
if (item is null)
{
throw new ArgumentNullException(nameof(item));
}
var token = await _tokenService.GenerateAsync(cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var record = new ShareLinkRecord
{
Id = Guid.NewGuid(),
TokenHash = token.TokenHash,
ItemId = item.Id.ToString("D"),
ItemNameSnapshot = item.Name ?? string.Empty,
CreatedByUserId = createdByUserId == Guid.Empty ? null : createdByUserId,
CreatedAtUtc = now,
ExpiresAtUtc = now.AddHours(expiryHours),
Status = ShareLinkStatus.Pending,
OneUse = oneUse,
AllowedTag = $"sharelinks-{Guid.NewGuid():N}",
CleanupAttempts = 0
};
await _store.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
try
{
if (!string.IsNullOrWhiteSpace(record.AllowedTag))
{
record.MetadataTouched = await _itemTagService.EnsureTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false);
}
record.Status = ShareLinkStatus.Active;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
return (record, token.Token);
}
catch (Exception ex)
{
record.Status = ShareLinkStatus.Failed;
record.CleanupError = ex.Message;
record.MetadataTouched = true;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
_logger.LogWarning(ex, "ShareLinks: failed to finish creation for record {RecordId}.", record.Id);
throw;
}
}
}
+269
Voir le fichier
@@ -0,0 +1,269 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Models;
using Jellyfin.Plugin.ShareLinks.Storage;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>Handles public share-link redemption and the bootstrap HTML response.</summary>
public sealed class ShareLinkRedemptionService
{
private readonly ILibraryManager _libraryManager;
private readonly ShareLinkStore _store;
private readonly ShareTokenService _tokenService;
private readonly ItemTagService _itemTagService;
private readonly JellyfinGuestUserService _guestUserService;
private readonly ShareLinkCleanupService _cleanupService;
private readonly ILogger<ShareLinkRedemptionService> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary>
public ShareLinkRedemptionService(
ILibraryManager libraryManager,
ShareLinkStore store,
ShareTokenService tokenService,
ItemTagService itemTagService,
JellyfinGuestUserService guestUserService,
ShareLinkCleanupService cleanupService,
ILogger<ShareLinkRedemptionService> logger)
{
_libraryManager = libraryManager;
_store = store;
_tokenService = tokenService;
_itemTagService = itemTagService;
_guestUserService = guestUserService;
_cleanupService = cleanupService;
_logger = logger;
}
/// <summary>Redeems a token and returns the bootstrap HTML, or null if the token is unusable.</summary>
public async Task<string?> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
{
var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false);
var record = await _store.GetByTokenHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
var now = DateTimeOffset.UtcNow;
if (record.ExpiresAtUtc <= now)
{
await HandleTerminalRecordAsync(record, ShareLinkStatus.Expired, "Share link has expired.", cancellationToken).ConfigureAwait(false);
return null;
}
if (record.Status == ShareLinkStatus.Revoked || record.Status == ShareLinkStatus.Failed)
{
return null;
}
if (!Guid.TryParse(record.ItemId, out var itemId))
{
await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false);
return null;
}
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
await HandleFailureAsync(record, "Shared item no longer exists.", cancellationToken).ConfigureAwait(false);
return null;
}
if (!string.IsNullOrWhiteSpace(record.AllowedTag))
{
await _itemTagService.EnsureTagAsync(item, record.AllowedTag!, cancellationToken).ConfigureAwait(false);
record.MetadataTouched = true;
}
if (record.OneUse && record.Status == ShareLinkStatus.Redeemed)
{
return null;
}
if (string.IsNullOrWhiteSpace(record.DeviceId))
{
record.DeviceId = Guid.NewGuid().ToString("N");
}
record.Status = ShareLinkStatus.Redeeming;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
var password = await GetOrCreatePasswordAsync(record, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(record.GuestUserName))
{
record.GuestUserName = JellyfinGuestUserService.BuildGuestUsername(record);
}
try
{
var user = await _guestUserService.EnsureGuestUserAsync(record, password, cancellationToken).ConfigureAwait(false);
record.GuestUserId = user.Id;
record.GuestUserName = user.Username;
record.RedeemedAtUtc ??= now;
record.Status = ShareLinkStatus.Redeemed;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
record.Status = ShareLinkStatus.Failed;
record.CleanupError = ex.Message;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
_logger.LogWarning(ex, "ShareLinks: failed to prepare guest session for record {RecordId}.", record.Id);
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
return null;
}
return BuildBootstrapHtml(request, record, password, itemId);
}
private async Task<string> GetOrCreatePasswordAsync(ShareLinkRecord record, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(record.GuestPasswordEncrypted))
{
try
{
return await _tokenService.UnprotectStringAsync(record.GuestPasswordEncrypted, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: stored guest password could not be decrypted for record {RecordId}; generating a replacement.", record.Id);
}
}
var password = JellyfinGuestUserService.GeneratePassword();
record.GuestPasswordEncrypted = await _tokenService.ProtectStringAsync(password, cancellationToken).ConfigureAwait(false);
record.Status = ShareLinkStatus.Redeeming;
record.CleanupError = null;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
return password;
}
private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken)
{
record.Status = terminalStatus;
record.CleanupError = reason;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
}
private async Task HandleFailureAsync(ShareLinkRecord record, string reason, CancellationToken cancellationToken)
{
record.Status = ShareLinkStatus.Failed;
record.CleanupError = reason;
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
await TryCleanupAsync(record, cancellationToken).ConfigureAwait(false);
}
private async Task TryCleanupAsync(ShareLinkRecord record, CancellationToken cancellationToken)
{
try
{
await _cleanupService.CleanupRecordAsync(record.Id, true, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ShareLinks: cleanup after failed redemption did not complete for record {RecordId}.", record.Id);
}
}
private static string BuildBootstrapHtml(HttpRequest request, ShareLinkRecord record, string password, Guid itemId)
{
var pathBase = request.PathBase.Value ?? string.Empty;
var authUrl = $"{pathBase}/Users/AuthenticateByName";
var redirectUrl = $"{pathBase}/web/index.html#!/details?id={Uri.EscapeDataString(itemId.ToString("D"))}";
var username = record.GuestUserName ?? JellyfinGuestUserService.BuildGuestUsername(record);
var deviceId = record.DeviceId ?? string.Empty;
var authJson = JsonSerializer.Serialize(new
{
Username = username,
Pw = password
});
var authUrlJson = JsonSerializer.Serialize(authUrl);
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
var usernameJson = JsonSerializer.Serialize(username);
var deviceIdJson = JsonSerializer.Serialize(deviceId);
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signing in...</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; }
main { max-width: 36rem; padding: 2rem; }
.muted { color: #9ca3af; }
</style>
</head>
<body>
<main>
<div>Signing you in...</div>
<div class="muted" id="status">Preparing temporary access.</div>
</main>
<script>
(async () => {
const authUrl = {{authUrlJson}};
const redirectUrl = {{redirectUrlJson}};
const username = {{usernameJson}};
const deviceId = {{deviceIdJson}} || crypto.randomUUID().replace(/-/g, "");
document.getElementById("status").textContent = "Authenticating " + username + ".";
const response = await fetch(authUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Emby-Authorization": `MediaBrowser Client="ShareLinks", Device="ShareLinks", DeviceId="${deviceId}", Version="1.0.0"`
},
body: {{authJson}}
});
if (!response.ok) {
throw new Error(`Authentication failed (${response.status})`);
}
const auth = await response.json();
const accessToken = auth.AccessToken ?? auth.accessToken ?? "";
const userId = auth.User?.Id ?? auth.user?.Id ?? auth.UserId ?? auth.userId ?? "";
const userName = auth.User?.Name ?? auth.user?.Name ?? auth.UserName ?? auth.userName ?? username;
const snapshot = {
AccessToken: accessToken,
UserId: userId,
UserName: userName,
ServerUrl: window.location.origin
};
try {
for (const key of ["jellyfinCredentials", "jellyfin_credentials", "jellyfin-credentials"]) {
localStorage.setItem(key, JSON.stringify(snapshot));
}
localStorage.setItem("jellyfin.server", window.location.origin);
} catch (_) {
// Best effort only. Jellyfin Web storage format should be verified live.
}
window.location.replace(redirectUrl);
})().catch((error) => {
console.error(error);
document.getElementById("status").textContent = "Sign-in failed.";
});
</script>
</body>
</html>
""";
}
}
+214
Voir le fichier
@@ -0,0 +1,214 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Models;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>
/// Generates raw share tokens and their persisted HMAC hashes.
/// </summary>
public sealed class ShareTokenService
{
private readonly string _secretPath;
private readonly ILogger<ShareTokenService> _logger;
private readonly SemaphoreSlim _secretGate = new(1, 1);
private byte[]? _secretKey;
/// <summary>Initializes a new instance of the <see cref="ShareTokenService"/> class.</summary>
public ShareTokenService(IApplicationPaths applicationPaths, ILogger<ShareTokenService> logger)
{
_secretPath = Path.Combine(applicationPaths.DataPath, "sharelinks", "token-secret.key");
_logger = logger;
}
/// <summary>Creates a new 256-bit token and its HMAC hash.</summary>
public async Task<ShareTokenMaterial> GenerateAsync(CancellationToken cancellationToken = default)
{
var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
var tokenBytes = new byte[32];
RandomNumberGenerator.Fill(tokenBytes);
var token = Base64UrlEncode(tokenBytes);
var hash = ComputeHash(secret, tokenBytes);
return new ShareTokenMaterial
{
Token = token,
TokenHash = hash
};
}
/// <summary>Computes the stored hash for a presented token.</summary>
public async Task<string> HashTokenAsync(string token, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(token))
{
throw new ArgumentException("Token cannot be empty.", nameof(token));
}
var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
var tokenBytes = Base64UrlDecode(token);
return ComputeHash(secret, tokenBytes);
}
/// <summary>Validates a token against an expected hash.</summary>
public async Task<bool> VerifyTokenAsync(string token, string expectedHash, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(expectedHash))
{
return false;
}
try
{
var actualHash = await HashTokenAsync(token, cancellationToken).ConfigureAwait(false);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(actualHash),
Encoding.UTF8.GetBytes(expectedHash));
}
catch (ArgumentException)
{
return false;
}
catch (FormatException)
{
return false;
}
}
/// <summary>Encrypts sensitive text using the shared plugin secret.</summary>
public async Task<string> ProtectStringAsync(string value, CancellationToken cancellationToken = default)
{
if (value is null)
{
throw new ArgumentNullException(nameof(value));
}
var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
var plaintext = Encoding.UTF8.GetBytes(value);
var nonce = new byte[12];
RandomNumberGenerator.Fill(nonce);
var cipher = new byte[plaintext.Length];
var tag = new byte[16];
using (var aes = new AesGcm(secret, 16))
{
aes.Encrypt(nonce, plaintext, cipher, tag);
}
var payload = new byte[nonce.Length + cipher.Length + tag.Length];
Buffer.BlockCopy(nonce, 0, payload, 0, nonce.Length);
Buffer.BlockCopy(cipher, 0, payload, nonce.Length, cipher.Length);
Buffer.BlockCopy(tag, 0, payload, nonce.Length + cipher.Length, tag.Length);
return Base64UrlEncode(payload);
}
/// <summary>Decrypts a sensitive string protected by <see cref="ProtectStringAsync"/>.</summary>
public async Task<string> UnprotectStringAsync(string protectedValue, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(protectedValue))
{
throw new ArgumentException("Protected value cannot be empty.", nameof(protectedValue));
}
var payload = Base64UrlDecode(protectedValue);
if (payload.Length < 12 + 16)
{
throw new CryptographicException("Protected payload is invalid.");
}
var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
var nonce = payload[..12];
var tag = payload[^16..];
var cipher = payload[12..^16];
var plaintext = new byte[cipher.Length];
using (var aes = new AesGcm(secret, 16))
{
aes.Decrypt(nonce, cipher, tag, plaintext);
}
return Encoding.UTF8.GetString(plaintext);
}
private async Task<byte[]> GetSecretAsync(CancellationToken cancellationToken)
{
if (_secretKey is not null)
{
return _secretKey;
}
await _secretGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_secretKey is not null)
{
return _secretKey;
}
if (File.Exists(_secretPath))
{
try
{
var secretText = await File.ReadAllTextAsync(_secretPath, cancellationToken).ConfigureAwait(false);
_secretKey = Base64UrlDecode(secretText.Trim());
if (_secretKey.Length >= 16)
{
return _secretKey;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: could not load the token secret; a new one will be generated.");
}
}
var generated = new byte[32];
RandomNumberGenerator.Fill(generated);
Directory.CreateDirectory(Path.GetDirectoryName(_secretPath)!);
await File.WriteAllTextAsync(_secretPath, Base64UrlEncode(generated), cancellationToken).ConfigureAwait(false);
_secretKey = generated;
return _secretKey;
}
finally
{
_secretGate.Release();
}
}
private static string ComputeHash(byte[] secret, ReadOnlySpan<byte> tokenBytes)
{
using var hmac = new HMACSHA256(secret);
return Base64UrlEncode(hmac.ComputeHash(tokenBytes.ToArray()));
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2:
padded += "==";
break;
case 3:
padded += "=";
break;
}
return Convert.FromBase64String(padded);
}
}