Fichiers
jellyfin-plugin-sharelinks/Jellyfin.Plugin.ShareLinks/Services/JellyfinGuestUserService.cs
T
Franciskid 224a79f7e5 cap how many people can watch one multi-use link at once
New setting, ten by default, zero for no limit. Single-use links are unaffected,
they are one viewer by definition.

The catch is what happens at the ceiling. Jellyfin throws SecurityException once a
user is at MaxActiveSessions, and that was landing in the generic handler, which
marks the record failed and runs cleanup, which deletes the guest account. So
without care, adding a ceiling would mean the eleventh person to open a link kicks
out the ten already watching and destroys the link. Capacity is caught separately
now: the record goes back to the state it was in, nothing is torn down, and the
new arrival gets a 503 page inviting them to try again.

Worth being honest that this caps how many people can start watching at once, not
how many ever get in: each redemption issues its own session token that keeps
working until the link is revoked or expires. Revoke is still the hard stop.

README picks up the multi-use option, the new setting, and a section on what a
multi-use link does and does not protect, plus the known limits around the token
in the query string, the unthrottled redeem endpoint, and the tag being hidden in
the web UI only.
2026-07-26 21:22:20 +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 gets the configured
// ceiling, where 0 is how Jellyfin spells "no limit" in its session check.
MaxActiveSessions = record.OneUse ? 1 : Math.Max(config.MaxConcurrentViewers, 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('/', '_');
}
}