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; /// Creates and tears down temporary Jellyfin guest users. public sealed class JellyfinGuestUserService { private readonly IUserManager _userManager; private readonly ILogger _logger; /// Initializes a new instance of the class. public JellyfinGuestUserService(IUserManager userManager, ILogger logger) { _userManager = userManager; _logger = logger; } /// Builds the temporary guest username for a share record. public static string BuildGuestUsername(ShareLinkRecord record) { var prefix = Plugin.Instance?.Configuration.GuestUsernamePrefix ?? "share-"; return $"{prefix}{record.Id:N}"; } /// Generates a strong random password suitable for a temporary guest user. public static string GeneratePassword() { var bytes = new byte[32]; RandomNumberGenerator.Fill(bytes); return Base64UrlEncode(bytes); } /// Ensures the temporary guest user exists and has the correct policy and password. public async Task 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( "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; } /// Disables a temporary guest user before deletion. 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); } } /// Deletes a temporary guest user if it exists. 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() : new[] { record.AllowedTag! }); SetPolicyValue(policy, "BlockedTags", Array.Empty()); 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()); SetPolicyValue(policy, "EnableContentDownloading", false); SetPolicyValue(policy, "EnableSyncTranscoding", false); SetPolicyValue(policy, "EnableMediaConversion", false); SetPolicyValue(policy, "EnableAllChannels", false); SetPolicyValue(policy, "EnabledChannels", Array.Empty()); SetPolicyValue(policy, "EnableAllDevices", true); SetPolicyValue(policy, "EnabledDevices", Array.Empty()); SetPolicyValue(policy, "EnableAllFolders", true); SetPolicyValue(policy, "EnabledFolders", Array.Empty()); SetPolicyValue(policy, "EnablePublicSharing", false); SetPolicyValue(policy, "LoginAttemptsBeforeLockout", -1); SetPolicyValue(policy, "MaxActiveSessions", 1); SetPolicyValue(policy, "BlockUnratedItems", Array.Empty()); await InvokeUserManagerAsync( "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( "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( "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 InvokeUserManagerAsync( string operationName, CancellationToken cancellationToken, params InvocationCandidate[] candidates) { var managerType = _userManager.GetType(); var triedVariants = new List(); 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(); 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(); 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(); 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 bytes) { return Convert.ToBase64String(bytes) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); } }