Restrict people, genres, studios and artists to names backed by an item the user can access
This commit is contained in:
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
@@ -103,6 +104,7 @@ public class PersonsController : BaseJellyfinApiController
|
||||
personTypes,
|
||||
excludePersonTypes)
|
||||
{
|
||||
AccessFilter = BuildAccessFilter(user),
|
||||
NameContains = searchTerm,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameLessThan = nameLessThan,
|
||||
@@ -123,6 +125,20 @@ public class PersonsController : BaseJellyfinApiController
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
// People are not owned by a library, so nothing in the Peoples table says which of them a user is
|
||||
// allowed to see; that only follows from the items they are credited on.
|
||||
private InternalItemsQuery? BuildAccessFilter(User? user)
|
||||
{
|
||||
if (user is null || !user.HasContentRestrictions())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var accessFilter = new InternalItemsQuery(user) { IncludeOwnedItems = true };
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
return accessFilter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get person by name.
|
||||
/// </summary>
|
||||
|
||||
@@ -162,6 +162,23 @@ public static class UserEntityExtensions
|
||||
return Array.IndexOf(GetPreferenceValues<Guid>(entity, PreferenceKind.GroupedFolders), id) != -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any library, parental rating or tag rule keeps content from this user.
|
||||
/// </summary>
|
||||
/// <param name="entity">The user to check.</param>
|
||||
/// <returns><c>True</c> if some content in the library is hidden from this user.</returns>
|
||||
public static bool HasContentRestrictions(this User entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
return !entity.HasPermission(PermissionKind.EnableAllFolders)
|
||||
|| entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0
|
||||
|| entity.MaxParentalRatingScore.HasValue
|
||||
|| entity.GetPreference(PreferenceKind.BlockedTags).Length > 0
|
||||
|| entity.GetPreference(PreferenceKind.AllowedTags).Length > 0
|
||||
|| entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the default permissions for a user. Should only be called on user creation.
|
||||
/// </summary>
|
||||
|
||||
@@ -1050,14 +1050,29 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
|
||||
var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0;
|
||||
if (enableItemsByName && includedItemByNameTypes.Count > 0)
|
||||
|
||||
// A by-name item belongs to no library, so it has no TopParentId to test and the filter
|
||||
// below would drop it. Items-by-name queries exempt the whole group; a query that names a
|
||||
// by-name type explicitly gets the same exemption, since it is asking for those items.
|
||||
var exemptedItemByNameTypes = enableItemsByName
|
||||
? includedItemByNameTypes
|
||||
: _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList();
|
||||
|
||||
if (exemptedItemByNameTypes.Count > 0)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
|
||||
baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
|
||||
}
|
||||
|
||||
// That exemption is what lets a by-name item from a library the user cannot open show up
|
||||
// in search. Decide those on the items behind the name instead.
|
||||
if (filter.UserHasContentRestrictions && exemptedItemByNameTypes.Count > 0)
|
||||
{
|
||||
baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.AncestorIds.Length > 0)
|
||||
@@ -1272,4 +1287,47 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a by-name row only when at least one item behind its name is reachable for the user.
|
||||
/// </summary>
|
||||
private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering(
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
JellyfinDbContext context,
|
||||
InternalItemsQuery filter,
|
||||
IReadOnlyList<string> itemByNameTypes,
|
||||
Guid[] topParentIds)
|
||||
{
|
||||
// IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts.
|
||||
var accessibleItems = ApplyAccessFiltering(
|
||||
context,
|
||||
context.BaseItems.AsNoTracking(),
|
||||
new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true });
|
||||
|
||||
// Each predicate is written outside-in - name row, then link table, then item - and with nested
|
||||
// Any() rather than a Contains over the accessible ids, which would materialise all of them
|
||||
// before the first row. That keeps every step an index seek.
|
||||
var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
if (itemByNameTypes.Contains(personType))
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Type != personType
|
||||
|| context.Peoples.Any(p => p.Name == e.Name
|
||||
&& context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId))));
|
||||
}
|
||||
|
||||
foreach (var (kind, valueTypes) in _itemByNameValueTypes)
|
||||
{
|
||||
var typeName = _itemTypeLookup.BaseItemKindNames[kind];
|
||||
if (!itemByNameTypes.Contains(typeName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
baseQuery = baseQuery.Where(e => e.Type != typeName
|
||||
|| context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName
|
||||
&& context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId))));
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,23 @@ public sealed partial class BaseItemRepository
|
||||
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
|
||||
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
|
||||
|
||||
private static readonly BaseItemKind[] _itemByNameKinds =
|
||||
[
|
||||
BaseItemKind.Person,
|
||||
BaseItemKind.Genre,
|
||||
BaseItemKind.MusicGenre,
|
||||
BaseItemKind.MusicArtist,
|
||||
BaseItemKind.Studio
|
||||
];
|
||||
|
||||
private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes =
|
||||
[
|
||||
(BaseItemKind.Genre, _getGenreValueTypes),
|
||||
(BaseItemKind.MusicGenre, _getGenreValueTypes),
|
||||
(BaseItemKind.MusicArtist, _getAllArtistsValueTypes),
|
||||
(BaseItemKind.Studio, _getStudiosValueTypes)
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
|
||||
/// </summary>
|
||||
|
||||
@@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item;
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">Efcore Factory.</param>
|
||||
/// <param name="itemTypeLookup">Items lookup service.</param>
|
||||
/// <param name="queryHelpers">Shared item query helpers.</param>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
|
||||
/// </remarks>
|
||||
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
|
||||
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
|
||||
|
||||
@@ -54,10 +55,18 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
.Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
|
||||
.OrderBy(e => e.Name.ToLower());
|
||||
|
||||
distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
|
||||
}
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
count = distinctNameCount ?? dbQuery.Count();
|
||||
}
|
||||
|
||||
var count = distinctNameCount ?? dbQuery.Count();
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
|
||||
{
|
||||
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
|
||||
@@ -250,6 +259,14 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
.AsNoTracking();
|
||||
}
|
||||
|
||||
if (filter.AccessFilter is not null)
|
||||
{
|
||||
// Keep only people credited on at least one item the user can see.
|
||||
var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter);
|
||||
query = query.Where(e => context.PeopleBaseItemMap
|
||||
.Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
|
||||
}
|
||||
|
||||
if (!filter.ItemId.IsEmpty())
|
||||
{
|
||||
query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
|
||||
|
||||
@@ -496,6 +496,12 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public IReadOnlyList<string> SubtitleLanguages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>.
|
||||
/// Filters that only exist to hide content can be skipped entirely when this is false.
|
||||
/// </summary>
|
||||
public bool UserHasContentRestrictions { get; private set; }
|
||||
|
||||
public void SetUser(User user)
|
||||
{
|
||||
var maxRating = user.MaxParentalRatingScore;
|
||||
@@ -519,6 +525,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
.Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
|
||||
.ToArray();
|
||||
|
||||
UserHasContentRestrictions = user.HasContentRestrictions();
|
||||
User = user;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
PersonTypes = personTypes;
|
||||
ExcludePersonTypes = excludePersonTypes;
|
||||
EnableTotalRecordCount = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to count the matching people. Under an
|
||||
/// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops
|
||||
/// at the limit, the count has to check every person.
|
||||
/// </summary>
|
||||
public bool EnableTotalRecordCount { get; set; }
|
||||
|
||||
public int? StartIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities
|
||||
public User User { get; set; }
|
||||
|
||||
public bool? IsFavorite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item query whose access settings (library access, parental rating, tags)
|
||||
/// people must satisfy through at least one of the items they are credited on.
|
||||
/// </summary>
|
||||
public InternalItemsQuery AccessFilter { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas
|
||||
builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role });
|
||||
builder.HasIndex(e => new { e.ItemId, e.SortOrder });
|
||||
builder.HasIndex(e => new { e.ItemId, e.ListOrder });
|
||||
builder.HasIndex(e => e.PeopleId);
|
||||
builder.HasIndex(e => new { e.PeopleId, e.ItemId });
|
||||
builder.HasOne(e => e.Item);
|
||||
builder.HasOne(e => e.People);
|
||||
}
|
||||
|
||||
+1813
File diff suppressed because it is too large
Load Diff
+36
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPeopleItemMapCoveringIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId",
|
||||
table: "PeopleBaseItemMap");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
|
||||
table: "PeopleBaseItemMap",
|
||||
columns: new[] { "PeopleId", "ItemId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
|
||||
table: "PeopleBaseItemMap");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId",
|
||||
table: "PeopleBaseItemMap",
|
||||
column: "PeopleId");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1060,12 +1060,12 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("ItemId", "PeopleId", "Role");
|
||||
|
||||
b.HasIndex("PeopleId");
|
||||
|
||||
b.HasIndex("ItemId", "ListOrder");
|
||||
|
||||
b.HasIndex("ItemId", "SortOrder");
|
||||
|
||||
b.HasIndex("PeopleId", "ItemId");
|
||||
|
||||
b.ToTable("PeopleBaseItemMap");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
|
||||
Reference in New Issue
Block a user