diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 3333b002f4..6cf12234ad 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -300,32 +300,29 @@ export type SearchOrder = z.infer; export type SearchFilter = z.infer; export type SearchFilterBranch = z.infer; +const NEW_SHAPE_FIELDS = ['filter', 'orderBy', 'cursor'] as const; + +export const isNewShapeRequest = (dto: Partial>): boolean => + NEW_SHAPE_FIELDS.some((field) => dto[field] !== undefined); + /** - * The structured shape and the deprecated flat search fields are - * mutually exclusive; carrier fields combine with either. + * The structured shape and the deprecated flat search fields are mutually exclusive * TODO(v4): remove together with the deprecated flat fields. */ -const withShapeExclusivity = >(schema: T) => - schema.superRefine((dto, ctx) => { - const NEW_SHAPE_FIELDS = ['filter', 'orderBy', 'cursor']; - const CARRIER_FIELDS = new Set([ - 'size', - 'withExif', - 'withStacked', - 'withPeople', - 'query', - 'queryAssetId', - 'language', - ]); +const withShapeExclusivity = >(schema: T) => { + const deprecatedFields = Object.keys(schema.shape).filter( + (field) => (schema.shape[field] as z.ZodType).meta()?.deprecated, + ); + return schema.superRefine((dto, ctx) => { const values = dto as Record; const newShapeFields = NEW_SHAPE_FIELDS.filter((field) => values[field] !== undefined); if (newShapeFields.length === 0) { return; } - for (const [field, value] of Object.entries(values)) { - if (value === undefined || CARRIER_FIELDS.has(field) || NEW_SHAPE_FIELDS.includes(field)) { + for (const field of deprecatedFields) { + if (values[field] === undefined) { continue; } @@ -336,11 +333,15 @@ const withShapeExclusivity = >(schema: T) = }); } }); +}; + +const filterField = SearchFilterSchema.optional().meta(ADDED_V3_1); +const cursorField = z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1); const RandomSearchBaseSchema = BaseSearchWithResultsSchema.extend({ withStacked: z.boolean().optional().describe('Include stacked assets'), withPeople: z.boolean().optional().describe('Include people data in response'), - filter: SearchFilterSchema.optional().meta(ADDED_V3_1), + filter: filterField, }); const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema).meta({ id: 'RandomSearchDto' }); @@ -358,14 +359,14 @@ const MetadataSearchSchema = withShapeExclusivity( order: AssetOrderSchema.optional().describe('Sort order').meta(DEPRECATED_FLAT_FIELD), page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD), orderBy: SearchOrderSchema.optional().meta(ADDED_V3_1), - cursor: z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1), + cursor: cursorField, }), ).meta({ id: 'MetadataSearchDto' }); const StatisticsSearchSchema = withShapeExclusivity( BaseSearchSchema.extend({ description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD), - filter: SearchFilterSchema.optional().meta(ADDED_V3_1), + filter: filterField, }), ).meta({ id: 'StatisticsSearchDto' }); @@ -375,8 +376,8 @@ const SmartSearchSchema = withShapeExclusivity( queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'), language: z.string().optional().describe('Search language code'), page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD), - filter: SearchFilterSchema.optional().meta(ADDED_V3_1), - cursor: z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1), + filter: filterField, + cursor: cursorField, }), ).meta({ id: 'SmartSearchDto' }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index c5972807ee..ef96e5ce70 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,14 +1,17 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { LRUMap } from 'mnemonist'; +import { SystemConfig } from 'src/config'; import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { + isNewShapeRequest, LargeAssetSearchDto, mapPlaces, MetadataSearchDto, PlacesResponseDto, RandomSearchDto, + SearchFilter, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -23,6 +26,13 @@ import { BaseService } from 'src/services/base.service'; import { requireElevatedPermission } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; +import { decodeSearchCursor, encodeSearchCursor } from 'src/utils/search-cursor'; +import { + applyLockedVisibilityPolicy, + collectFilterIds, + hasTopLevelPositiveIdsConstraint, + isLockedOnlyFilter, +} from 'src/utils/search-filter'; @Injectable() export class SearchService extends BaseService { @@ -63,6 +73,10 @@ export class SearchService extends BaseService { } async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise { + if (isNewShapeRequest(dto)) { + return this.searchMetadataV3(auth, dto); + } + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } @@ -96,10 +110,14 @@ export class SearchService extends BaseService { }, ); - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + return this.mapResponse(items, { auth }, { nextPage: hasNextPage ? (page + 1).toString() : null }); } async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise { + if (isNewShapeRequest(dto)) { + return this.searchStatisticsV3(auth, dto); + } + const userIds = await this.getUserIdsToSearch(auth, dto.visibility); if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); @@ -113,6 +131,10 @@ export class SearchService extends BaseService { } async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + if (isNewShapeRequest(dto)) { + return this.searchRandomV3(auth, dto); + } + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } @@ -141,6 +163,10 @@ export class SearchService extends BaseService { } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { + if (isNewShapeRequest(dto)) { + return this.searchSmartV3(auth, dto); + } + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } @@ -151,28 +177,7 @@ export class SearchService extends BaseService { } const userIds = this.getUserIdsToSearch(auth, dto.visibility); - let embedding; - if (dto.query) { - const key = machineLearning.clip.modelName + dto.query + dto.language; - embedding = this.embeddingCache.get(key); - if (!embedding) { - embedding = await this.machineLearningRepository.encodeText(dto.query, { - modelName: machineLearning.clip.modelName, - language: dto.language, - }); - this.embeddingCache.set(key, embedding); - } - } else if (dto.queryAssetId) { - await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] }); - const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId); - const assetEmbedding = getEmbeddingResponse?.embedding; - if (!assetEmbedding) { - throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`); - } - embedding = assetEmbedding; - } else { - throw new BadRequestException('Either `query` or `queryAssetId` must be set'); - } + const embedding = await this.resolveEmbedding(auth, dto, machineLearning); const page = dto.page ?? 1; const size = dto.size || 100; const { hasNextPage, items } = await this.searchRepository.searchSmart( @@ -185,7 +190,7 @@ export class SearchService extends BaseService { }, ); - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + return this.mapResponse(items, { auth }, { nextPage: hasNextPage ? (page + 1).toString() : null }); } async getAssetsByCity(auth: AuthDto): Promise { @@ -229,6 +234,121 @@ export class SearchService extends BaseService { } } + private async searchMetadataV3(auth: AuthDto, dto: MetadataSearchDto): Promise { + const filter = dto.filter ?? {}; + const effectiveFilter = applyLockedVisibilityPolicy(auth, filter); + + // only a top-level any/all constrains every result to accessible albums; anything else keeps + // the ownership scope so an OR branch cannot widen the search to other users' assets + const hasTopLevelAlbums = hasTopLevelPositiveIdsConstraint(filter, 'albumIds'); + if (auth.sharedLink && !hasTopLevelAlbums) { + throw new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'); + } + + const albumIds = collectFilterIds(filter, 'albumIds'); + const [userIds] = await Promise.all([ + hasTopLevelAlbums ? undefined : this.getUserIdsToSearchV3(auth, filter), + albumIds.length > 0 ? this.requireAccess({ auth, ids: albumIds, permission: Permission.AlbumRead }) : undefined, + ]); + + const { offset } = decodeSearchCursor(dto.cursor); + const size = dto.size || 250; + const { hasNextPage, items } = await this.searchRepository.searchMetadataV3( + { size, offset }, + { + filter: effectiveFilter, + userIds, + withExif: dto.withExif, + withPeople: dto.withPeople, + withStacked: dto.withStacked, + order: dto.orderBy, + }, + ); + + return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null }); + } + + private async searchStatisticsV3(auth: AuthDto, dto: StatisticsSearchDto): Promise { + const { effectiveFilter, userIds } = await this.resolveSearchScopeV3(auth, dto); + return this.searchRepository.searchStatisticsV3({ filter: effectiveFilter, userIds }); + } + + private async searchRandomV3(auth: AuthDto, dto: RandomSearchDto): Promise { + const { effectiveFilter, userIds } = await this.resolveSearchScopeV3(auth, dto); + const items = await this.searchRepository.searchRandomV3(dto.size || 250, { + filter: effectiveFilter, + userIds, + withExif: dto.withExif, + withPeople: dto.withPeople, + withStacked: dto.withStacked, + }); + return items.map((item) => mapAsset(item, { auth })); + } + + private async searchSmartV3(auth: AuthDto, dto: SmartSearchDto): Promise { + const { machineLearning } = await this.getConfig({ withCache: false }); + if (!isSmartSearchEnabled(machineLearning)) { + throw new BadRequestException('Smart search is not enabled'); + } + + const [{ effectiveFilter, userIds }, embedding] = await Promise.all([ + this.resolveSearchScopeV3(auth, dto), + this.resolveEmbedding(auth, dto, machineLearning), + ]); + + const { offset } = decodeSearchCursor(dto.cursor); + const size = dto.size || 100; + const { hasNextPage, items } = await this.searchRepository.searchSmartV3( + { size, offset }, + { filter: effectiveFilter, userIds, withExif: dto.withExif, embedding }, + ); + + return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null }); + } + + private async resolveSearchScopeV3(auth: AuthDto, dto: { filter?: SearchFilter }) { + const filter = dto.filter ?? {}; + return { + effectiveFilter: applyLockedVisibilityPolicy(auth, filter), + userIds: await this.getUserIdsToSearchV3(auth, filter), + }; + } + + private getUserIdsToSearchV3(auth: AuthDto, filter: SearchFilter): Promise { + return this.getUserIdsToSearch(auth, isLockedOnlyFilter(filter) ? AssetVisibility.Locked : undefined); + } + + private async resolveEmbedding( + auth: AuthDto, + dto: SmartSearchDto, + machineLearning: SystemConfig['machineLearning'], + ): Promise { + if (dto.query) { + const key = machineLearning.clip.modelName + dto.query + dto.language; + let embedding = this.embeddingCache.get(key); + if (!embedding) { + embedding = await this.machineLearningRepository.encodeText(dto.query, { + modelName: machineLearning.clip.modelName, + language: dto.language, + }); + this.embeddingCache.set(key, embedding); + } + return embedding; + } + + if (dto.queryAssetId) { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] }); + const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId); + const assetEmbedding = getEmbeddingResponse?.embedding; + if (!assetEmbedding) { + throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`); + } + return assetEmbedding; + } + + throw new BadRequestException('Either `query` or `queryAssetId` must be set'); + } + private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise { // Locked assets are personal. Never include partner IDs, regardless of A's elevated session. if (visibility === AssetVisibility.Locked) { @@ -244,9 +364,8 @@ export class SearchService extends BaseService { private mapResponse( assets: MapAsset[], - nextPage: string | null, options: AssetMapOptions, - nextCursor: string | null = null, + page: { nextPage?: string | null; nextCursor?: string | null } = {}, ): SearchResponseDto { return { albums: { total: 0, count: 0, items: [], facets: [] }, @@ -255,8 +374,8 @@ export class SearchService extends BaseService { count: assets.length, items: assets.map((asset) => mapAsset(asset, options)), facets: [], - nextPage, - nextCursor, + nextPage: page.nextPage ?? null, + nextCursor: page.nextCursor ?? null, }, }; } diff --git a/server/src/utils/search-cursor.spec.ts b/server/src/utils/search-cursor.spec.ts index c1326c1754..5106a283bf 100644 --- a/server/src/utils/search-cursor.spec.ts +++ b/server/src/utils/search-cursor.spec.ts @@ -18,6 +18,10 @@ describe('encodeSearchCursor', () => { describe('decodeSearchCursor', () => { const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + it('should treat a missing cursor as the first page', () => { + expect(decodeSearchCursor(undefined)).toEqual({ offset: 0 }); + }); + it.each([ ['empty string', ''], ['garbage that is not base64url', '!!!not-base64!!!'], diff --git a/server/src/utils/search-cursor.ts b/server/src/utils/search-cursor.ts index f73c48c878..1fe8f926b7 100644 --- a/server/src/utils/search-cursor.ts +++ b/server/src/utils/search-cursor.ts @@ -5,12 +5,14 @@ const SearchCursorPayloadSchema = z.object({ o: z.int().min(0), }); -type SearchCursorPayload = z.infer; - export const encodeSearchCursor = (offset: number): string => - Buffer.from(JSON.stringify({ o: offset } satisfies SearchCursorPayload)).toString('base64url'); + Buffer.from(JSON.stringify({ o: offset } satisfies z.infer)).toString('base64url'); + +export const decodeSearchCursor = (cursor?: string): { offset: number } => { + if (cursor === undefined) { + return { offset: 0 }; + } -export const decodeSearchCursor = (cursor: string): { offset: number } => { try { const payload = SearchCursorPayloadSchema.parse(JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))); return { offset: payload.o }; diff --git a/server/src/utils/search-filter.spec.ts b/server/src/utils/search-filter.spec.ts index 98556d8f55..0a2510559b 100644 --- a/server/src/utils/search-filter.spec.ts +++ b/server/src/utils/search-filter.spec.ts @@ -1,10 +1,16 @@ import { UnauthorizedException } from '@nestjs/common'; +import { SearchFilter } from 'src/dtos/search.dto'; import { AssetVisibility } from 'src/enum'; -import { applyLockedVisibilityPolicy } from 'src/utils/search-filter'; +import { + applyLockedVisibilityPolicy, + collectFilterIds, + hasTopLevelPositiveIdsConstraint, + isLockedOnlyFilter, +} from 'src/utils/search-filter'; import { AuthFactory } from 'test/factories/auth.factory'; import { describe, expect, it } from 'vitest'; -const { Locked, Timeline, Archive } = AssetVisibility; +const { Locked, Timeline, Archive, Hidden } = AssetVisibility; const elevatedAuth = () => AuthFactory.from().session({ hasElevatedPermission: true }).build(); const unelevatedAuth = () => AuthFactory.from().session().build(); @@ -50,3 +56,53 @@ describe(applyLockedVisibilityPolicy.name, () => { expect(applyLockedVisibilityPolicy(unelevatedAuth(), branched).visibility).toEqual({ ne: Locked }); }); }); + +describe(isLockedOnlyFilter.name, () => { + it('should detect provably locked-only filters', () => { + expect(isLockedOnlyFilter({ visibility: { eq: Locked } })).toBe(true); + expect(isLockedOnlyFilter({ visibility: { in: [Locked] } })).toBe(true); + expect(isLockedOnlyFilter({ visibility: { notIn: [Timeline, Archive, Hidden] } })).toBe(true); + }); + + it('should not match other filters', () => { + expect(isLockedOnlyFilter({ visibility: { ne: Locked } })).toBe(false); + expect(isLockedOnlyFilter({ visibility: { in: [Locked, Timeline] } })).toBe(false); + expect(isLockedOnlyFilter({ or: [{ visibility: { eq: Locked } }] })).toBe(false); + expect(isLockedOnlyFilter({})).toBe(false); + }); +}); + +describe(collectFilterIds.name, () => { + it('should union and dedupe ids across operators and branches', () => { + const [albumA, albumB, albumC] = [ + '00000000-0000-4000-8000-00000000000a', + '00000000-0000-4000-8000-00000000000b', + '00000000-0000-4000-8000-00000000000c', + ]; + const filter: SearchFilter = { + albumIds: { any: [albumA], none: [albumB] }, + or: [{ albumIds: { all: [albumC, albumA] } }, { city: { eq: 'Oslo' } }], + }; + expect(collectFilterIds(filter, 'albumIds').toSorted()).toEqual([albumA, albumB, albumC]); + expect(collectFilterIds({}, 'albumIds')).toEqual([]); + }); + + it('should only collect ids of the requested field', () => { + const albumId = '00000000-0000-4000-8000-00000000000a'; + const personId = '00000000-0000-4000-8000-00000000000b'; + const filter = { albumIds: { any: [albumId] }, personIds: { any: [personId] } }; + expect(collectFilterIds(filter, 'personIds')).toEqual([personId]); + }); +}); + +describe(hasTopLevelPositiveIdsConstraint.name, () => { + it('should detect only top-level any/all constraints', () => { + const albumId = '00000000-0000-4000-8000-00000000000a'; + expect(hasTopLevelPositiveIdsConstraint({ albumIds: { any: [albumId] } }, 'albumIds')).toBe(true); + expect(hasTopLevelPositiveIdsConstraint({ albumIds: { all: [albumId] } }, 'albumIds')).toBe(true); + expect(hasTopLevelPositiveIdsConstraint({ albumIds: { none: [albumId] } }, 'albumIds')).toBe(false); + expect(hasTopLevelPositiveIdsConstraint({ or: [{ albumIds: { any: [albumId] } }] }, 'albumIds')).toBe(false); + expect(hasTopLevelPositiveIdsConstraint({ albumIds: { any: [albumId] } }, 'tagIds')).toBe(false); + expect(hasTopLevelPositiveIdsConstraint({}, 'albumIds')).toBe(false); + }); +}); diff --git a/server/src/utils/search-filter.ts b/server/src/utils/search-filter.ts index f1de995653..172a55b882 100644 --- a/server/src/utils/search-filter.ts +++ b/server/src/utils/search-filter.ts @@ -4,26 +4,32 @@ import { AssetVisibility } from 'src/enum'; import { requireElevatedPermission } from 'src/utils/access'; type EnumField = 'type' | 'visibility'; -type EnumValue = NonNullable['eq']>; -type EnumCondition = { eq?: EnumValue; ne?: EnumValue; in?: EnumValue[]; notIn?: EnumValue[] }; +type EnumCondition = { eq?: T; ne?: T; in?: T[]; notIn?: T[] }; +type IdsFilterField = 'albumIds' | 'personIds' | 'tagIds'; + +const filterBranches = (filter: SearchFilter): SearchFilterBranch[] => [filter, ...(filter.or ?? [])]; /** Whether a row with `value` can satisfy the condition. A missing operator allows any value. */ -const canMatch = (condition: EnumCondition, value: EnumValue): boolean => +const canMatch = (condition: EnumCondition, value: T): 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)); +const matchesOnly = (condition: EnumCondition, value: T, values: T[]): boolean => + values.every((other) => other === value || !canMatch(condition, other)); + /** - * The conditions that decide which `field` values the filter can return. A top-level condition - * decides alone, otherwise each branch itself. + * 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[] => { +const decidingConditions = (filter: SearchFilter, field: F) => { if (filter[field] !== undefined) { return [filter[field]]; } - return (filter.or ?? []).map((branch) => branch[field]).filter((condition) => condition !== undefined); + return filterBranches(filter) + .map((branch) => branch[field]) + .filter((condition) => condition !== undefined); }; /** @@ -45,3 +51,26 @@ export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter) return { ...filter, visibility: { ne: AssetVisibility.Locked } }; }; + +/** Whether the top-level visibility condition limits the filter to locked assets only. */ +export const isLockedOnlyFilter = ({ visibility }: SearchFilter): boolean => + visibility !== undefined && + matchesOnly(visibility, AssetVisibility.Locked, Object.values(AssetVisibility)); + +export const collectFilterIds = (filter: SearchFilter, field: IdsFilterField): string[] => { + const ids = new Set(); + + for (const branch of filterBranches(filter)) { + for (const operator of ['any', 'all', 'none'] as const) { + for (const id of branch[field]?.[operator] ?? []) { + ids.add(id); + } + } + } + + return [...ids]; +}; + +/** Whether the top level constrains results to specific `field` ids (`any`/`all`; `none` only excludes). */ +export const hasTopLevelPositiveIdsConstraint = (filter: SearchFilter, field: IdsFilterField): boolean => + filter[field]?.any !== undefined || filter[field]?.all !== undefined;