mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
enum condition filters
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { applyLockedVisibilityPolicy } from 'src/utils/search-filter';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const { Locked, Timeline, Archive } = AssetVisibility;
|
||||
|
||||
const elevatedAuth = () => AuthFactory.from().session({ hasElevatedPermission: true }).build();
|
||||
const unelevatedAuth = () => AuthFactory.from().session().build();
|
||||
|
||||
describe(applyLockedVisibilityPolicy.name, () => {
|
||||
it('should let an elevated session query locked assets', () => {
|
||||
const filter = { visibility: { eq: Locked } };
|
||||
expect(applyLockedVisibilityPolicy(elevatedAuth(), filter)).toBe(filter);
|
||||
});
|
||||
|
||||
it('should reject an unelevated session when any operator permits locked', () => {
|
||||
for (const visibility of [{ eq: Locked }, { ne: Timeline }, { in: [Locked, Timeline] }, { notIn: [Timeline] }]) {
|
||||
expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), { visibility })).toThrow(UnauthorizedException);
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep a filter whose top-level visibility excludes locked', () => {
|
||||
for (const visibility of [{ eq: Timeline }, { ne: Locked }, { in: [Timeline, Archive] }, { notIn: [Locked] }]) {
|
||||
const filter = { visibility };
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter);
|
||||
}
|
||||
});
|
||||
|
||||
it('should let a safe top-level visibility decide over could-match branches', () => {
|
||||
const filter = { visibility: { ne: Locked }, or: [{ visibility: { eq: Locked } }] };
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter);
|
||||
});
|
||||
|
||||
it('should reject a branch that permits locked when the top level has no visibility', () => {
|
||||
const filter = { or: [{ city: { eq: 'Oslo' } }, { visibility: { in: [Locked, Timeline] } }] };
|
||||
expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should otherwise inject visibility != locked without mutating the input', () => {
|
||||
const filter = { city: { eq: 'Oslo' }, visibility: undefined };
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toEqual({
|
||||
city: { eq: 'Oslo' },
|
||||
visibility: { ne: Locked },
|
||||
});
|
||||
expect(filter.visibility).toBeUndefined();
|
||||
|
||||
const branched = { or: [{ isFavorite: { eq: true } }, { visibility: { eq: Timeline } }] };
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), branched).visibility).toEqual({ ne: Locked });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SearchFilter, SearchFilterBranch } from 'src/dtos/search.dto';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
|
||||
type EnumField = 'type' | 'visibility';
|
||||
type EnumValue = NonNullable<NonNullable<SearchFilterBranch[EnumField]>['eq']>;
|
||||
type EnumCondition = { eq?: EnumValue; ne?: EnumValue; in?: EnumValue[]; notIn?: EnumValue[] };
|
||||
|
||||
/** Whether a row with `value` can satisfy the condition. A missing operator allows any value. */
|
||||
const canMatch = (condition: EnumCondition, value: EnumValue): boolean =>
|
||||
(condition.eq === undefined || condition.eq === value) &&
|
||||
(condition.ne === undefined || condition.ne !== value) &&
|
||||
(condition.in === undefined || condition.in.includes(value)) &&
|
||||
(condition.notIn === undefined || !condition.notIn.includes(value));
|
||||
|
||||
/**
|
||||
* The conditions that decide which `field` values the filter can return. A top-level condition
|
||||
* decides alone, otherwise each branch itself.
|
||||
*/
|
||||
const decidingConditions = (filter: SearchFilter, field: EnumField): EnumCondition[] => {
|
||||
if (filter[field] !== undefined) {
|
||||
return [filter[field]];
|
||||
}
|
||||
|
||||
return (filter.or ?? []).map((branch) => branch[field]).filter((condition) => condition !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps locked assets out of search results unless the session is elevated: a filter that asks for
|
||||
* them is rejected with 401, and any other filter gets `visibility != locked` ANDed in.
|
||||
*/
|
||||
export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter): SearchFilter => {
|
||||
if (auth.session?.hasElevatedPermission) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
if (decidingConditions(filter, 'visibility').some((condition) => canMatch(condition, AssetVisibility.Locked))) {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
||||
if (filter.visibility !== undefined) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
return { ...filter, visibility: { ne: AssetVisibility.Locked } };
|
||||
};
|
||||
Reference in New Issue
Block a user