Jellyfin does not cascade a user delete to the Device rows redemption creates, and DeviceManager throws for the whole listing when one device's user is missing, so a single leftover guest 404s the dashboard devices page. Deletes the guest's devices before the user, and sweeps devices this plugin created whose user is already gone at startup for links torn down by older builds.
291 lignes
11 KiB
C#
291 lignes
11 KiB
C#
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;
|
|
|
|
/// <summary>Creates and tears down temporary Jellyfin guest users.</summary>
|
|
public sealed class JellyfinGuestUserService
|
|
{
|
|
/// <summary>
|
|
/// The app name redemption stamps on a guest session. Used to tell this plugin's
|
|
/// device rows apart from everything else on the server.
|
|
/// </summary>
|
|
public const string GuestAppName = "ShareLinks";
|
|
|
|
private readonly IUserManager _userManager;
|
|
private readonly IDeviceManager _deviceManager;
|
|
private readonly ILogger<JellyfinGuestUserService> _logger;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="JellyfinGuestUserService"/> class.</summary>
|
|
public JellyfinGuestUserService(
|
|
IUserManager userManager,
|
|
IDeviceManager deviceManager,
|
|
ILogger<JellyfinGuestUserService> logger)
|
|
{
|
|
_userManager = userManager;
|
|
_deviceManager = deviceManager;
|
|
_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
|
|
{
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Removes every device row belonging to a guest account.</summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<int> 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<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('/', '_');
|
|
}
|
|
}
|