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.
195 lines
6.9 KiB
C#
195 lines
6.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net.Mime;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.WatchedTogether.Models;
|
|
using Jellyfin.Plugin.WatchedTogether.Services;
|
|
using MediaBrowser.Common.Api;
|
|
using MediaBrowser.Controller.Library;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.WatchedTogether.Controllers;
|
|
|
|
/// <summary>
|
|
/// Administrative endpoints backing the configuration page.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Authorize(Policy = Policies.RequiresElevation)]
|
|
[Route("Plugins/WatchedTogether")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
public class WatchedTogetherController : ControllerBase
|
|
{
|
|
private readonly IProvisioningService _provisioningService;
|
|
private readonly IUserManager _userManager;
|
|
private readonly ILogger<WatchedTogetherController> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="WatchedTogetherController"/> class.
|
|
/// </summary>
|
|
/// <param name="provisioningService">The provisioning service.</param>
|
|
/// <param name="userManager">The user manager.</param>
|
|
/// <param name="logger">The logger.</param>
|
|
public WatchedTogetherController(
|
|
IProvisioningService provisioningService,
|
|
IUserManager userManager,
|
|
ILogger<WatchedTogetherController> logger)
|
|
{
|
|
_provisioningService = provisioningService;
|
|
_userManager = userManager;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets every configured group, resolved against current user records.
|
|
/// </summary>
|
|
/// <returns>The configured groups.</returns>
|
|
[HttpGet("Groups")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public ActionResult<IEnumerable<GroupDto>> GetGroups()
|
|
{
|
|
var config = Plugin.Instance?.Configuration;
|
|
if (config is null)
|
|
{
|
|
return Ok(Array.Empty<GroupDto>());
|
|
}
|
|
|
|
var groups = config.Groups.Select(g => new GroupDto
|
|
{
|
|
SharedUserId = g.SharedUserId,
|
|
SharedUsername = _userManager.GetUserById(g.SharedUserId)?.Username ?? "(deleted)",
|
|
SyncUnwatched = g.SyncUnwatched,
|
|
SyncPlayCount = g.SyncPlayCount,
|
|
IsDisabled = g.IsDisabled,
|
|
Members = g.MemberUserIds.Select(id => new MemberDto
|
|
{
|
|
UserId = id,
|
|
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
|
|
}).ToList()
|
|
}).ToList();
|
|
|
|
return Ok(groups);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the users that may be selected as members - everyone who is not already a shared account.
|
|
/// </summary>
|
|
/// <returns>The eligible users.</returns>
|
|
[HttpGet("EligibleUsers")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public ActionResult<IEnumerable<MemberDto>> GetEligibleUsers()
|
|
{
|
|
var sharedIds = Plugin.Instance?.Configuration.Groups
|
|
.Select(g => g.SharedUserId)
|
|
.ToHashSet() ?? [];
|
|
|
|
var users = _userManager.Users
|
|
.Where(u => !sharedIds.Contains(u.Id))
|
|
.Select(u => new MemberDto { UserId = u.Id, Username = u.Username })
|
|
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
return Ok(users);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a shared account and its group.
|
|
/// </summary>
|
|
/// <param name="request">The group to create.</param>
|
|
/// <returns>The created group.</returns>
|
|
[HttpPost("Groups")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult<GroupDto>> CreateGroup([FromBody] CreateGroupRequest request)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
try
|
|
{
|
|
var group = await _provisioningService.CreateGroupAsync(
|
|
request.MemberUserIds,
|
|
request.Name).ConfigureAwait(false);
|
|
|
|
return Ok(new GroupDto
|
|
{
|
|
SharedUserId = group.SharedUserId,
|
|
SharedUsername = _userManager.GetUserById(group.SharedUserId)?.Username ?? string.Empty,
|
|
SyncUnwatched = group.SyncUnwatched,
|
|
SyncPlayCount = group.SyncPlayCount,
|
|
IsDisabled = group.IsDisabled,
|
|
Members = group.MemberUserIds.Select(id => new MemberDto
|
|
{
|
|
UserId = id,
|
|
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
|
|
}).ToList()
|
|
});
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Rejected group creation request");
|
|
return BadRequest(ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing group's membership and options.
|
|
/// </summary>
|
|
/// <param name="sharedUserId">The shared account identifying the group.</param>
|
|
/// <param name="request">The new membership and options.</param>
|
|
/// <returns>No content on success.</returns>
|
|
[HttpPost("Groups/{sharedUserId}")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult> UpdateGroup(
|
|
[FromRoute] Guid sharedUserId,
|
|
[FromBody] UpdateGroupRequest request)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
try
|
|
{
|
|
await _provisioningService.UpdateGroupAsync(
|
|
sharedUserId,
|
|
request.MemberUserIds,
|
|
request.SyncUnwatched,
|
|
request.SyncPlayCount,
|
|
request.IsDisabled).ConfigureAwait(false);
|
|
|
|
return NoContent();
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Rejected group update request");
|
|
return BadRequest(ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a group, optionally deleting its shared account too.
|
|
/// </summary>
|
|
/// <param name="sharedUserId">The shared account identifying the group.</param>
|
|
/// <param name="deleteSharedUser">Whether to delete the shared account as well.</param>
|
|
/// <returns>No content on success.</returns>
|
|
[HttpDelete("Groups/{sharedUserId}")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult> DeleteGroup(
|
|
[FromRoute] Guid sharedUserId,
|
|
[FromQuery] bool deleteSharedUser = false)
|
|
{
|
|
try
|
|
{
|
|
await _provisioningService.DeleteGroupAsync(sharedUserId, deleteSharedUser).ConfigureAwait(false);
|
|
return NoContent();
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Rejected group deletion request");
|
|
return BadRequest(ex.Message);
|
|
}
|
|
}
|
|
}
|