Previously the shared account's libraries were chosen independently of its members, so a group could see a library that one of its members was blocked from - joining a group became a way to gain access. That was especially sharp with auto-created groups, where no admin is in the loop. A shared account is now granted exactly the libraries every member can already reach. If one member is blocked from a library, no group containing them can see it. The account is therefore always a subset of what each member could reach alone, which is what makes creating groups at the login screen safe to leave on by default. Details: - "Enable all folders" is expanded to concrete library ids before intersecting, since it cannot otherwise be compared with an explicit list. Shared accounts are always given an explicit list, never the all-folders permission, so newly added libraries do not silently widen an existing group. - Explicitly blocked folders are subtracted even for members who otherwise have access to everything. - Fails closed: an unresolvable member contributes no access rather than being treated as unrestricted. - Recomputed when membership changes, and re-applied to every group at startup so narrowing a member's own access narrows their groups. Drops the now-meaningless EnableAllFolders/EnabledFolders provisioning inputs and the DynamicGroupsEnableAllFolders setting. Adds 8 tests covering the intersection rules.
115 lines
3.9 KiB
C#
115 lines
3.9 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using MediaBrowser.Controller.Library;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
|
|
|
/// <summary>
|
|
/// Keeps group membership consistent with the set of users that actually exist.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Jellyfin raises no user-deleted event that carries the removed id, so instead of subscribing to
|
|
/// deletions this reconciles configuration against live users at startup. Stale entries are also
|
|
/// skipped at read time by <see cref="GroupService.GetEligibleMembers"/>, so this is about keeping
|
|
/// stored configuration tidy and disabling groups that have fallen below two members.
|
|
/// </remarks>
|
|
public sealed class UserLifecycleService : IHostedService
|
|
{
|
|
private readonly IUserManager _userManager;
|
|
private readonly IGroupService _groupService;
|
|
private readonly ILibraryAccessService _libraryAccessService;
|
|
private readonly ILogger<UserLifecycleService> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="UserLifecycleService"/> class.
|
|
/// </summary>
|
|
/// <param name="userManager">The user manager.</param>
|
|
/// <param name="groupService">The group service.</param>
|
|
/// <param name="libraryAccessService">The library access service.</param>
|
|
/// <param name="logger">The logger.</param>
|
|
public UserLifecycleService(
|
|
IUserManager userManager,
|
|
IGroupService groupService,
|
|
ILibraryAccessService libraryAccessService,
|
|
ILogger<UserLifecycleService> logger)
|
|
{
|
|
_userManager = userManager;
|
|
_groupService = groupService;
|
|
_libraryAccessService = libraryAccessService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
Reconcile();
|
|
await ReapplyLibraryAccessAsync().ConfigureAwait(false);
|
|
}
|
|
#pragma warning disable CA1031 // Reconciliation must never prevent the server from starting.
|
|
catch (Exception ex)
|
|
#pragma warning restore CA1031
|
|
{
|
|
_logger.LogError(ex, "Failed to reconcile Watched Together groups at startup");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recomputes every group's library access.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A member's own library access can be narrowed at any time through the normal user editor,
|
|
/// which would leave a group's stored intersection too wide. Recomputing at startup brings
|
|
/// shared accounts back in line without needing to hook every policy change.
|
|
/// </remarks>
|
|
/// <returns>A task representing the update.</returns>
|
|
private async Task ReapplyLibraryAccessAsync()
|
|
{
|
|
var config = Plugin.Instance?.Configuration;
|
|
if (config is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var group in config.Groups.ToList())
|
|
{
|
|
await _libraryAccessService
|
|
.ApplyIntersectionAsync(group.SharedUserId, group.MemberUserIds)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
/// <summary>
|
|
/// Removes references to users that no longer exist.
|
|
/// </summary>
|
|
private void Reconcile()
|
|
{
|
|
var config = Plugin.Instance?.Configuration;
|
|
if (config is null || config.Groups.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var liveIds = _userManager.UsersIds.ToHashSet();
|
|
|
|
var referenced = config.Groups
|
|
.SelectMany(g => g.MemberUserIds.Append(g.SharedUserId))
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
foreach (var id in referenced.Where(id => !liveIds.Contains(id)))
|
|
{
|
|
_logger.LogInformation("Pruning deleted user {UserId} from Watched Together groups", id);
|
|
_groupService.PruneDeletedUser(id);
|
|
}
|
|
}
|
|
}
|