Fichiers
jellyfin-plugin-sharelinks/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs
T
Franciskid f0b9e8351b let a link be used by several people instead of dying on first open
The create popup now has a "Let several people use this link" box next to the
expiry controls. Tick it and the link stays redeemable by everyone you send it to
until it expires; leave it and you get the old behaviour, where the first person
to open it is the only one who gets in. The plugin setting that used to be the
only control is now just what the box starts out as, and its label on the config
page says so, because "Default one-use links" explained nothing.

Multi-use did not actually work before this. Two things in Jellyfin stopped it,
and both had to change:

Guests were given MaxActiveSessions = 1, and AuthenticateNewSessionInternal throws
SecurityException once a user is at that limit. The second viewer's redemption
would fail, the record would go to Failed, and cleanup would then delete the guest
account, kicking the first viewer out too. Multi-use links now get 0, which is how
Jellyfin spells "no limit" in that check.

The device id was generated once and reused for every redemption, and
GetAuthorizationToken logs out every existing session for the same user and device
before issuing a token. So even under a raised session cap, each new viewer would
have revoked the previous one's token. Multi-use links now mint a device id per
redemption.

Both viewers of a multi-use link share one temporary account, so they also share
playback position and watched state on the shared title.
2026-07-26 21:05:54 +02:00

207 lignes
8.0 KiB
C#

using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
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<User> 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));
}
cancellationToken.ThrowIfCancellationRequested();
var username = record.GuestUserName;
if (string.IsNullOrWhiteSpace(username))
{
username = BuildGuestUsername(record);
record.GuestUserName = username;
}
var user = _userManager.GetUserByName(username);
if (user is null)
{
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
if (user is null)
{
throw new InvalidOperationException($"Unable to create temporary guest user '{username}'.");
}
}
// The password must be set before the policy update: UpdatePolicyAsync bumps the
// user's EF concurrency token server side, and ChangePassword with a stale instance
// then throws DbUpdateConcurrencyException. The password is only a fallback - the
// 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);
user = _userManager.GetUserById(user.Id) ?? user;
_logger.LogInformation("ShareLinks: ensured guest user {UserName} for record {RecordId}.", user.Username, 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;
}
cancellationToken.ThrowIfCancellationRequested();
try
{
await ApplyPolicyAsync(user, record, disabled: true).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: failed to disable guest user {UserName} for record {RecordId}.", user.Username, 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;
}
cancellationToken.ThrowIfCancellationRequested();
try
{
await _userManager.DeleteUserAsync(user.Id).ConfigureAwait(false);
_logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", user.Username, record.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: failed to delete guest user {UserName} for record {RecordId}.", user.Username, record.Id);
throw;
}
}
private User? FindRecordUser(ShareLinkRecord record)
{
if (record.GuestUserId.HasValue)
{
var user = _userManager.GetUserById(record.GuestUserId.Value);
if (user is not null)
{
return user;
}
}
return string.IsNullOrWhiteSpace(record.GuestUserName)
? null
: _userManager.GetUserByName(record.GuestUserName);
}
private async Task ApplyPolicyAsync(User user, ShareLinkRecord record, bool disabled)
{
var config = Plugin.Instance!.Configuration;
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,
// One viewer for a one-use link. A multi-use link needs a session per
// viewer, and 0 is how Jellyfin spells "no limit" in its session check.
MaxActiveSessions = record.OneUse ? 1 : 0,
BlockUnratedItems = Array.Empty<UnratedItem>()
};
await _userManager.UpdatePolicyAsync(user.Id, policy).ConfigureAwait(false);
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}