using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.WatchedTogether.Services; /// /// Grants a shared account only the libraries that every member can already reach. /// /// /// Sharing an account must never be a way to gain access. If Alice is blocked from a library, a /// group she belongs to cannot see it either, even when every other member can. The result is the /// intersection of the members' access, so the shared account is always a subset of what each /// member could reach alone. /// public class LibraryAccessService : ILibraryAccessService { private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The user manager. /// The library manager. /// The logger. public LibraryAccessService( IUserManager userManager, ILibraryManager libraryManager, ILogger logger) { _userManager = userManager; _libraryManager = libraryManager; _logger = logger; } /// public IReadOnlyList ComputeIntersection(IReadOnlyList memberIds) { ArgumentNullException.ThrowIfNull(memberIds); if (memberIds.Count == 0) { return []; } HashSet? intersection = null; foreach (var memberId in memberIds) { var member = _userManager.GetUserById(memberId); if (member is null) { // A member we cannot resolve contributes no access. Treating it as "everything" // would silently widen the group. _logger.LogWarning( "Member {MemberId} could not be resolved; treating its library access as empty", memberId); return []; } var accessible = GetAccessibleLibraries(member); if (intersection is null) { intersection = accessible; } else { intersection.IntersectWith(accessible); } // Nothing left in common; no later member can widen it again. if (intersection.Count == 0) { break; } } return intersection?.ToList() ?? (IReadOnlyList)[]; } /// public async Task> ApplyIntersectionAsync( Guid sharedUserId, IReadOnlyList memberIds) { var granted = ComputeIntersection(memberIds); var sharedUser = _userManager.GetUserById(sharedUserId); if (sharedUser is null) { return granted; } // Always an explicit list, never EnableAllFolders: "all" would keep growing as libraries // are added, silently granting the group access nobody agreed to. var policy = _userManager.GetUserDto(sharedUser).Policy; if (policy is null) { _logger.LogWarning( "Could not read the policy for shared account {SharedUserId}; library access unchanged", sharedUserId); return granted; } policy.EnableAllFolders = false; policy.EnabledFolders = granted.ToArray(); await _userManager.UpdatePolicyAsync(sharedUserId, policy).ConfigureAwait(false); if (granted.Count == 0) { _logger.LogWarning( "Shared account {SharedUserId} has no libraries: its members have none in common", sharedUserId); } else { _logger.LogInformation( "Shared account {SharedUserId} granted {Count} libraries common to all members", sharedUserId, granted.Count); } return granted; } /// /// Resolves the concrete set of libraries a single user can reach. /// /// The user to inspect. /// The library identifiers this user can see. private HashSet GetAccessibleLibraries(User user) { // "Enable all folders" has to be expanded into concrete ids before it can be intersected // with another member's explicit list. var accessible = user.HasPermission(PermissionKind.EnableAllFolders) ? GetAllLibraryIds() : user.GetPreferenceValues(PreferenceKind.EnabledFolders).ToHashSet(); // An explicitly blocked folder is removed even when the user otherwise has access to all. accessible.ExceptWith(user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders)); return accessible; } /// /// Gets the identifiers of every top-level library on the server. /// /// All library identifiers. private HashSet GetAllLibraryIds() { try { return _libraryManager.GetUserRootFolder() .Children .Select(c => c.Id) .ToHashSet(); } #pragma warning disable CA1031 // Failing closed is the safe direction for an access decision. catch (Exception ex) #pragma warning restore CA1031 { _logger.LogError(ex, "Could not enumerate libraries; treating access as empty"); return []; } } }