using System; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Data.Queries; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Plugin.ShareLinks.Models; using MediaBrowser.Controller.Devices; 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 { /// /// The app name redemption stamps on a guest session. Used to tell this plugin's /// device rows apart from everything else on the server. /// public const string GuestAppName = "ShareLinks"; private readonly IUserManager _userManager; private readonly IDeviceManager _deviceManager; private readonly ILogger _logger; /// Initializes a new instance of the class. public JellyfinGuestUserService( IUserManager userManager, IDeviceManager deviceManager, ILogger logger) { _userManager = userManager; _deviceManager = deviceManager; _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 { // Devices first. Jellyfin does not cascade a user delete to the Device // rows that redemption created, and a Device whose user is gone makes // DeviceManager.ToDeviceInfo throw for the WHOLE listing, so one leftover // guest 404s the admin dashboard's devices page entirely. await DeleteDevicesForUserAsync(user.Id).ConfigureAwait(false); 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; } } /// Removes every device row belonging to a guest account. private async Task DeleteDevicesForUserAsync(Guid userId) { // GetDevices returns the raw entities. GetDevicesForUser/GetDevice project to // DTOs and resolve the owning user on the way, which is exactly what throws // once the user is gone, so neither of those can be used to clean up after it. var devices = _deviceManager.GetDevices(new DeviceQuery { UserId = userId }); foreach (var device in devices.Items) { try { await _deviceManager.DeleteDevice(device).ConfigureAwait(false); _logger.LogInformation( "ShareLinks: deleted device {DeviceId} ({AppName}) for guest {UserId}.", device.DeviceId, device.AppName, userId); } catch (Exception ex) { _logger.LogWarning(ex, "ShareLinks: failed to delete device {DeviceId}.", device.DeviceId); } } } /// /// Deletes device rows left behind by guests that were removed before this /// cleanup existed. Scoped to devices this plugin created, so a stale row from /// anything else on the server is left for its owner to deal with. /// public async Task PurgeOrphanedGuestDevicesAsync(CancellationToken cancellationToken) { var removed = 0; var devices = _deviceManager.GetDevices(new DeviceQuery()); foreach (var device in devices.Items) { cancellationToken.ThrowIfCancellationRequested(); if (!string.Equals(device.AppName, GuestAppName, StringComparison.Ordinal)) { continue; } if (_userManager.GetUserById(device.UserId) is not null) { continue; } try { await _deviceManager.DeleteDevice(device).ConfigureAwait(false); removed++; _logger.LogInformation( "ShareLinks: removed orphaned guest device {DeviceId} whose user {UserId} no longer exists.", device.DeviceId, device.UserId); } catch (Exception ex) { _logger.LogWarning(ex, "ShareLinks: failed to remove orphaned device {DeviceId}.", device.DeviceId); } } return removed; } 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 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() }; await _userManager.UpdatePolicyAsync(user.Id, policy).ConfigureAwait(false); } private static string Base64UrlEncode(ReadOnlySpan bytes) { return Convert.ToBase64String(bytes) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); } }