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; /// 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)); } 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; } /// Disables a temporary guest user before deletion. 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); } } /// 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; } 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() : new[] { record.AllowedTag! }, BlockedTags = Array.Empty(), 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(), EnableContentDownloading = false, EnableSyncTranscoding = false, EnableMediaConversion = false, EnableAllChannels = false, EnabledChannels = Array.Empty(), EnableAllDevices = true, EnabledDevices = Array.Empty(), EnableAllFolders = true, EnabledFolders = Array.Empty(), 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() }; await _userManager.UpdatePolicyAsync(user.Id, policy).ConfigureAwait(false); } private static string Base64UrlEncode(ReadOnlySpan bytes) { return Convert.ToBase64String(bytes) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); } }