call Jellyfin's APIs directly instead of probing for them at runtime

JellyfinGuestUserService looked up IUserManager methods by reflection, trying
eight candidate signatures for ChangePassword alone, and ItemTagService did the
same for UpdateItemAsync. That fails at runtime on any API drift and only logs a
warning, which is exactly how the DbUpdateConcurrencyException hunt started. We
already pin Jellyfin.Controller 10.11, so these are now plain typed calls and any
future drift is a compile error. 427 lines of shim gone, behaviour unchanged
(UpdateItemAsync still gets ItemUpdateType.None, password still set before the
policy update).

Guest accounts also get their own authentication provider now, which refuses every
interactive sign-in. Redemption is unaffected: AuthenticateDirect passes
enforcePassword false and never consults a provider. If the plugin is disabled the
provider id stops resolving and Jellyfin assigns the account to its own
InvalidAuthProvider, which refuses too, so this fails closed. A random password is
still set as a second line of defence.
Cette révision appartient à :
Franciskid
2026-07-26 16:47:18 +02:00
Parent 96299be57c
révision f1989f8824
5 fichiers modifiés avec 135 ajouts et 426 suppressions
+2
Voir le fichier
@@ -3,6 +3,7 @@ using Jellyfin.Plugin.ShareLinks.Services;
using Jellyfin.Plugin.ShareLinks.Storage; using Jellyfin.Plugin.ShareLinks.Storage;
using Jellyfin.Plugin.ShareLinks.Web; using Jellyfin.Plugin.ShareLinks.Web;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -24,6 +25,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton<ShareTokenService>(); serviceCollection.AddSingleton<ShareTokenService>();
serviceCollection.AddSingleton<ItemTagService>(); serviceCollection.AddSingleton<ItemTagService>();
serviceCollection.AddSingleton<JellyfinGuestUserService>(); serviceCollection.AddSingleton<JellyfinGuestUserService>();
serviceCollection.AddSingleton<IAuthenticationProvider, GuestAuthenticationProvider>();
serviceCollection.AddSingleton<ShareLinkCreationService>(); serviceCollection.AddSingleton<ShareLinkCreationService>();
serviceCollection.AddSingleton<ShareLinkRedemptionService>(); serviceCollection.AddSingleton<ShareLinkRedemptionService>();
serviceCollection.AddSingleton<ShareLinkCleanupService>(); serviceCollection.AddSingleton<ShareLinkCleanupService>();
+53
Voir le fichier
@@ -0,0 +1,53 @@
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Authentication;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>
/// The authentication provider assigned to ShareLinks guest accounts. It refuses
/// every interactive sign-in, so a guest account cannot be used on the normal
/// login page even if its name and password were to leak. Guest sessions are
/// minted server side through <c>ISessionManager.AuthenticateDirect</c>, which
/// does not enforce a password and so never reaches a provider at all.
/// </summary>
public sealed class GuestAuthenticationProvider : IAuthenticationProvider
{
private readonly ILogger<GuestAuthenticationProvider> _logger;
/// <summary>Initializes a new instance of the <see cref="GuestAuthenticationProvider"/> class.</summary>
public GuestAuthenticationProvider(ILogger<GuestAuthenticationProvider> logger)
{
_logger = logger;
}
/// <summary>
/// Gets the value Jellyfin stores on a user to select this provider. Jellyfin
/// matches it against the provider's full type name.
/// </summary>
public static string ProviderId => typeof(GuestAuthenticationProvider).FullName!;
/// <inheritdoc />
public string Name => "ShareLinks guest accounts (blocks sign-in)";
/// <inheritdoc />
public bool IsEnabled => true;
/// <inheritdoc />
public Task<ProviderAuthenticationResult> Authenticate(string username, string password)
{
_logger.LogWarning("ShareLinks: refused an interactive sign-in attempt for guest account {UserName}.", username);
return Task.FromException<ProviderAuthenticationResult>(
new AuthenticationException("ShareLinks guest accounts cannot sign in interactively."));
}
/// <summary>
/// Reports the account as having a password so nothing offers it as a
/// passwordless login.
/// </summary>
public bool HasPassword(User user) => true;
/// <inheritdoc />
public Task ChangePassword(User user, string newPassword) => Task.CompletedTask;
}
+2 -41
Voir le fichier
@@ -1,12 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services; namespace Jellyfin.Plugin.ShareLinks.Services;
@@ -157,46 +157,7 @@ public sealed class ItemTagService
private async Task PersistAsync(BaseItem item, CancellationToken cancellationToken) 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 parent = item.DisplayParent ?? item;
var task = method.Invoke(_libraryManager, new object?[] await _libraryManager.UpdateItemAsync(item, parent, ItemUpdateType.None, cancellationToken).ConfigureAwait(false);
{
item,
parent,
updateReason,
cancellationToken
}) as Task;
if (task is null)
{
throw new InvalidOperationException("UpdateItemAsync did not return a task.");
}
await task.ConfigureAwait(false);
} }
} }
+74 -384
Voir le fichier
@@ -1,12 +1,9 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Configuration; using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.ShareLinks.Models; using Jellyfin.Plugin.ShareLinks.Models;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Users; using MediaBrowser.Model.Users;
@@ -43,7 +40,7 @@ public sealed class JellyfinGuestUserService
} }
/// <summary>Ensures the temporary guest user exists and has the correct policy and password.</summary> /// <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) public async Task<User> EnsureGuestUserAsync(ShareLinkRecord record, string password, CancellationToken cancellationToken)
{ {
if (record is null) if (record is null)
{ {
@@ -55,6 +52,8 @@ public sealed class JellyfinGuestUserService
throw new ArgumentException("Password cannot be empty.", nameof(password)); throw new ArgumentException("Password cannot be empty.", nameof(password));
} }
cancellationToken.ThrowIfCancellationRequested();
var username = record.GuestUserName; var username = record.GuestUserName;
if (string.IsNullOrWhiteSpace(username)) if (string.IsNullOrWhiteSpace(username))
{ {
@@ -62,42 +61,27 @@ public sealed class JellyfinGuestUserService
record.GuestUserName = username; record.GuestUserName = username;
} }
object? user = _userManager.GetUserByName(username); var user = _userManager.GetUserByName(username);
if (user is null) if (user is null)
{ {
user = await InvokeUserManagerAsync<object?>( user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
"create user",
cancellationToken,
new InvocationCandidate("CreateUserAsync", new object?[] { username }),
new InvocationCandidate("CreateUser", new object?[] { username }))
.ConfigureAwait(false) ?? _userManager.GetUserByName(username);
if (user is null) if (user is null)
{ {
throw new InvalidOperationException($"Unable to create temporary guest user '{username}'."); throw new InvalidOperationException($"Unable to create temporary guest user '{username}'.");
} }
} }
var existingUserId = GetUserId(user); // The password must be set before the policy update: UpdatePolicyAsync bumps the
if (existingUserId != Guid.Empty) // user's EF concurrency token server side, and ChangePassword with a stale instance
{ // then throws DbUpdateConcurrencyException. The password is only a fallback - the
user = _userManager.GetUserById(existingUserId) ?? user; // policy hands the account to GuestAuthenticationProvider, which refuses every
} // interactive sign-in - but it means the account is never reachable with a blank
// password either.
await _userManager.ChangePassword(user, password).ConfigureAwait(false);
await ApplyPolicyAsync(user, record, disabled: false).ConfigureAwait(false);
// The password must be changed before the policy update: UpdatePolicyAsync bumps the user = _userManager.GetUserById(user.Id) ?? user;
// user's EF concurrency token server-side, and ChangePassword with a stale instance _logger.LogInformation("ShareLinks: ensured guest user {UserName} for record {RecordId}.", user.Username, record.Id);
// 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; return user;
} }
@@ -110,13 +94,14 @@ public sealed class JellyfinGuestUserService
return; return;
} }
cancellationToken.ThrowIfCancellationRequested();
try try
{ {
await ApplyPolicyAsync(user, record, disabled: true, cancellationToken).ConfigureAwait(false); await ApplyPolicyAsync(user, record, disabled: true).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "ShareLinks: failed to disable guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); _logger.LogWarning(ex, "ShareLinks: failed to disable guest user {UserName} for record {RecordId}.", user.Username, record.Id);
} }
} }
@@ -129,19 +114,20 @@ public sealed class JellyfinGuestUserService
return; return;
} }
cancellationToken.ThrowIfCancellationRequested();
try try
{ {
await DeleteUserAsync(user, cancellationToken).ConfigureAwait(false); await _userManager.DeleteUserAsync(user.Id).ConfigureAwait(false);
_logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); _logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", user.Username, record.Id);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "ShareLinks: failed to delete guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); _logger.LogWarning(ex, "ShareLinks: failed to delete guest user {UserName} for record {RecordId}.", user.Username, record.Id);
throw; throw;
} }
} }
private object? FindRecordUser(ShareLinkRecord record) private User? FindRecordUser(ShareLinkRecord record)
{ {
if (record.GuestUserId.HasValue) if (record.GuestUserId.HasValue)
{ {
@@ -152,358 +138,62 @@ public sealed class JellyfinGuestUserService
} }
} }
if (!string.IsNullOrWhiteSpace(record.GuestUserName)) return string.IsNullOrWhiteSpace(record.GuestUserName)
{ ? null
return _userManager.GetUserByName(record.GuestUserName); : _userManager.GetUserByName(record.GuestUserName);
}
return null;
} }
private async Task ApplyPolicyAsync(object user, ShareLinkRecord record, bool disabled, CancellationToken cancellationToken) private async Task ApplyPolicyAsync(User user, ShareLinkRecord record, bool disabled)
{ {
var config = Plugin.Instance!.Configuration; var config = Plugin.Instance!.Configuration;
var policy = new UserPolicy(); var policy = new UserPolicy
{
// Hand the account to a provider that refuses interactive sign-in. If the
// plugin is ever disabled the id stops resolving and Jellyfin falls back to
// its own InvalidAuthProvider, which also refuses, so this fails closed.
AuthenticationProviderId = GuestAuthenticationProvider.ProviderId,
PasswordResetProviderId = user.PasswordResetProviderId,
AllowedTags = string.IsNullOrWhiteSpace(record.AllowedTag)
? Array.Empty<string>()
: new[] { record.AllowedTag! },
BlockedTags = Array.Empty<string>(),
IsAdministrator = false,
IsHidden = true,
IsDisabled = disabled,
EnableCollectionManagement = false,
EnableSubtitleManagement = false,
EnableLyricManagement = false,
EnableUserPreferenceAccess = false,
EnableSharedDeviceControl = false,
EnableRemoteAccess = true,
EnableRemoteControlOfOtherUsers = false,
EnableLiveTvManagement = false,
EnableLiveTvAccess = false,
EnableMediaPlayback = true,
EnableAudioPlaybackTranscoding = config.AllowTranscoding,
EnableVideoPlaybackTranscoding = config.AllowTranscoding,
EnablePlaybackRemuxing = config.AllowRemuxing,
ForceRemoteSourceTranscoding = false,
EnableContentDeletion = false,
EnableContentDeletionFromFolders = Array.Empty<string>(),
EnableContentDownloading = false,
EnableSyncTranscoding = false,
EnableMediaConversion = false,
EnableAllChannels = false,
EnabledChannels = Array.Empty<Guid>(),
EnableAllDevices = true,
EnabledDevices = Array.Empty<string>(),
EnableAllFolders = true,
EnabledFolders = Array.Empty<Guid>(),
EnablePublicSharing = false,
LoginAttemptsBeforeLockout = -1,
MaxActiveSessions = 1,
BlockUnratedItems = Array.Empty<UnratedItem>()
};
SetPolicyValue(policy, "AuthenticationProviderId", GetUserValue(user, "AuthenticationProviderId")); await _userManager.UpdatePolicyAsync(user.Id, policy).ConfigureAwait(false);
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) private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{ {
return Convert.ToBase64String(bytes) return Convert.ToBase64String(bytes)
+4 -1
Voir le fichier
@@ -107,7 +107,10 @@ on the server, using Jellyfin's own session manager. No password is ever stored
anywhere, not even encrypted, and no password ever appears in the page sent to anywhere, not even encrypted, and no password ever appears in the page sent to
the guest. The only thing the guest's browser receives is a session token the guest. The only thing the guest's browser receives is a session token
scoped to that one guest account, and that token dies the moment the guest scoped to that one guest account, and that token dies the moment the guest
account is cleaned up. account is cleaned up. On top of that, the guest account is assigned an authentication provider that
refuses every interactive sign-in, so the normal login page cannot be used to get
into a guest account at all, password or not. If the plugin is disabled Jellyfin
falls back to its own invalid-provider handling, which refuses too.
## Configuration ## Configuration