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,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);
|
||||
}
|
||||
}
|
||||
Référencer dans un nouveau ticket
Bloquer un utilisateur