diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 3278afb97c..81c4b35980 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -449,14 +449,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $2 + $4 offset - $3 + $5 -- SearchRepository.searchMetadataV3 (empty) select @@ -492,14 +496,18 @@ from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - true + ( + "asset"."visibility" != $1 + or "asset"."ownerId" = $2 + ) + and true order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $1 + $3 offset - $2 + $4 -- SearchRepository.searchMetadataV3 (or-exif-only) select @@ -536,14 +544,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and "asset_exif"."city" = $2 + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and "asset_exif"."city" = $4 order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (string-eq-null) select @@ -580,14 +592,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and "asset_exif"."city" is null order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $2 + $4 offset - $3 + $5 -- SearchRepository.searchMetadataV3 (string-pattern-like) select @@ -624,14 +640,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%') + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($4) || '%') order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (string-pattern-notLike) select @@ -668,14 +688,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%') + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($4) || '%') order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (string-pattern-startsWith) select @@ -712,14 +736,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%') + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($4) || '%') order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (string-similarity-ocr) select @@ -756,21 +784,25 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select from "ocr_search" where "ocr_search"."assetId" = "asset"."id" - and f_unaccent (ocr_search.text) %>> f_unaccent ($2) + and f_unaccent (ocr_search.text) %>> f_unaccent ($4) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (ids-any) select @@ -807,21 +839,25 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select from "album_asset" where "album_asset"."assetId" = "asset"."id" - and "album_asset"."albumId" = any ($2::uuid[]) + and "album_asset"."albumId" = any ($4::uuid[]) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (ids-all) select @@ -858,6 +894,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select "asset_face"."assetId" @@ -866,20 +906,20 @@ where where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $2 - and "asset_face"."personId" = any ($3::uuid[]) + and "asset_face"."isVisible" = $4 + and "asset_face"."personId" = any ($5::uuid[]) group by "asset_face"."assetId" having - count(distinct "asset_face"."personId") = $4 + count(distinct "asset_face"."personId") = $6 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $5 + $7 offset - $6 + $8 -- SearchRepository.searchMetadataV3 (ids-all-single) select @@ -916,21 +956,25 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select from "album_asset" where "album_asset"."assetId" = "asset"."id" - and "album_asset"."albumId" = any ($2::uuid[]) + and "album_asset"."albumId" = any ($4::uuid[]) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (ids-none) select @@ -967,6 +1011,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and not exists ( select from @@ -974,15 +1022,15 @@ where inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" where "tag_asset"."assetId" = "asset"."id" - and "tag_closure"."id_ancestor" = any ($2::uuid[]) + and "tag_closure"."id_ancestor" = any ($4::uuid[]) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (ids-tags-all) select @@ -1019,6 +1067,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select "tag_asset"."assetId" @@ -1027,19 +1079,19 @@ where inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" where "tag_asset"."assetId" = "asset"."id" - and "tag_closure"."id_ancestor" = any ($2::uuid[]) + and "tag_closure"."id_ancestor" = any ($4::uuid[]) group by "tag_asset"."assetId" having - count(distinct "tag_closure"."id_ancestor") = $3 + count(distinct "tag_closure"."id_ancestor") = $5 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $4 + $6 offset - $5 + $7 -- SearchRepository.searchMetadataV3 (has-albums-false) select @@ -1076,6 +1128,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and not exists ( select from @@ -1087,9 +1143,9 @@ order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $2 + $4 offset - $3 + $5 -- SearchRepository.searchMetadataV3 (is-encoded) select @@ -1126,21 +1182,25 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select from "asset_file" where "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = $2 + and "asset_file"."type" = $4 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (number-range) select @@ -1178,16 +1238,20 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset_exif"."fileSizeInByte" <= $2 - and "asset_exif"."fileSizeInByte" >= $3 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset_exif"."fileSizeInByte" <= $4 + and "asset_exif"."fileSizeInByte" >= $5 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $4 + $6 offset - $5 + $7 -- SearchRepository.searchMetadataV3 (date-eq) select @@ -1224,14 +1288,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and "asset"."fileCreatedAt" = $2 + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and "asset"."fileCreatedAt" = $4 order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchMetadataV3 (date-range) select @@ -1269,16 +1337,20 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset"."fileCreatedAt" < $2 - and "asset"."fileCreatedAt" >= $3 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."fileCreatedAt" < $4 + and "asset"."fileCreatedAt" >= $5 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $4 + $6 offset - $5 + $7 -- SearchRepository.searchMetadataV3 (order-fileSize-noExif) select @@ -1315,14 +1387,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by "asset_exif"."fileSizeInByte" desc nulls last, "asset"."id" desc limit - $2 + $4 offset - $3 + $5 -- SearchRepository.searchMetadataV3 (order-rating-withExif) select @@ -1360,14 +1436,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by "asset_exif"."rating" asc nulls last, "asset"."id" asc limit - $2 + $4 offset - $3 + $5 -- SearchRepository.searchMetadataV3 (or-branches) select @@ -1405,7 +1485,11 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset"."isFavorite" = $2 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."isFavorite" = $4 or exists ( select from @@ -1413,17 +1497,17 @@ where where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $3 - and "asset_face"."personId" = any ($4::uuid[]) + and "asset_face"."isVisible" = $5 + and "asset_face"."personId" = any ($6::uuid[]) ) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $5 + $7 offset - $6 + $8 -- SearchRepository.searchMetadataV3 (or-with-top-level) select @@ -1461,17 +1545,21 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset"."fileCreatedAt" < $2 - and "asset"."fileCreatedAt" >= $3 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."fileCreatedAt" < $4 + and "asset"."fileCreatedAt" >= $5 and ( - "asset"."isFavorite" = $4 + "asset"."isFavorite" = $6 or exists ( select from "album_asset" where "album_asset"."assetId" = "asset"."id" - and "album_asset"."albumId" = any ($5::uuid[]) + and "album_asset"."albumId" = any ($7::uuid[]) ) ) ) @@ -1479,9 +1567,9 @@ order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $6 + $8 offset - $7 + $9 -- SearchRepository.searchMetadataV3 (cursor-offset) select @@ -1518,14 +1606,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and "asset"."isFavorite" = $2 + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and "asset"."isFavorite" = $4 order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 offset - $4 + $6 -- SearchRepository.searchRandomV3 (baseline) select @@ -1562,11 +1654,15 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by random() limit - $2 + $4 -- SearchRepository.searchRandomV3 (with-filter) select @@ -1603,11 +1699,15 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and "asset"."isFavorite" = $2 + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and "asset"."isFavorite" = $4 order by random() limit - $3 + $5 -- SearchRepository.searchSmartV3 (baseline) begin @@ -1648,14 +1748,18 @@ from inner join "smart_search" on "asset"."id" = "smart_search"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by - smart_search.embedding <=> $2, + smart_search.embedding <=> $4, "asset"."id" asc limit - $3 + $5 offset - $4 + $6 commit -- SearchRepository.searchSmartV3 (with-filter) @@ -1698,16 +1802,20 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset"."fileCreatedAt" < $2 - and "asset"."fileCreatedAt" >= $3 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."fileCreatedAt" < $4 + and "asset"."fileCreatedAt" >= $5 ) order by - smart_search.embedding <=> $4, + smart_search.embedding <=> $6, "asset"."id" asc limit - $5 + $7 offset - $6 + $8 commit -- SearchRepository.searchSmartV3 (cursor-offset) @@ -1749,14 +1857,18 @@ from inner join "smart_search" on "asset"."id" = "smart_search"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by - smart_search.embedding <=> $2, + smart_search.embedding <=> $4, "asset"."id" asc limit - $3 + $5 offset - $4 + $6 commit -- SearchRepository.searchStatisticsV3 (baseline) @@ -1767,6 +1879,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true -- SearchRepository.searchStatisticsV3 (with-filter) @@ -1778,9 +1894,13 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset_exif"."fileSizeInByte" >= $2 - and "asset"."fileCreatedAt" < $3 - and "asset"."fileCreatedAt" >= $4 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset_exif"."fileSizeInByte" >= $4 + and "asset"."fileCreatedAt" < $5 + and "asset"."fileCreatedAt" >= $6 ) -- SearchRepository.searchStatisticsV3 (with-or) @@ -1792,7 +1912,11 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset"."isFavorite" = $2 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."isFavorite" = $4 or not exists ( select from diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index fe6f05703b..6a7ec55130 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -135,10 +135,13 @@ export type AssetSearchOptions = Omit & export type AssetSearchBuilderOptions = Omit; +export interface AssetSearchScope { + userIds?: string[]; + lockedOwnerId: string; +} + export interface AssetSearchBuilderV3Options { filter?: SearchFilter; - /** Server-derived ownership scope. Never client-controlled. */ - userIds?: string[]; withExif?: boolean; withFaces?: boolean; withPeople?: boolean; @@ -528,8 +531,12 @@ export class SearchRepository { // TODO(v4): drop the V3 suffix once the legacy methods are removed @GenerateSql(...searchMetadataV3Examples) - async searchMetadataV3(pagination: AssetSearchPaginationV3Options, options: AssetSearchBuilderV3Options) { - const items = await withSearchOrder(searchAssetBuilder(this.db, options), options.order) + async searchMetadataV3( + pagination: AssetSearchPaginationV3Options, + options: AssetSearchBuilderV3Options, + scope: AssetSearchScope, + ) { + const items = await withSearchOrder(searchAssetBuilder(this.db, options, scope), options.order) .select(columns.searchAsset) .limit(pagination.size + 1) .offset(pagination.offset ?? 0) @@ -539,8 +546,12 @@ export class SearchRepository { // TODO(v4): drop the V3 suffix once the legacy methods are removed @GenerateSql(...searchRandomV3Examples) - searchRandomV3(size: number, options: Omit): Promise { - return searchAssetBuilder(this.db, options) + searchRandomV3( + size: number, + options: Omit, + scope: AssetSearchScope, + ): Promise { + return searchAssetBuilder(this.db, options, scope) .select(columns.searchAsset) .orderBy(sql`random()`) .limit(size) @@ -552,10 +563,11 @@ export class SearchRepository { searchSmartV3( pagination: AssetSearchPaginationV3Options, options: Omit & { embedding: string }, + scope: AssetSearchScope, ) { return this.db.transaction().execute(async (trx) => { await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx); - const items = await searchAssetBuilder(trx, options) + const items = await searchAssetBuilder(trx, options, scope) .select(columns.searchAsset) .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) @@ -569,8 +581,8 @@ export class SearchRepository { // TODO(v4): drop the V3 suffix once the legacy methods are removed @GenerateSql(...searchStatisticsV3Examples) - searchStatisticsV3(options: AssetSearchBuilderV3Options) { - return searchAssetBuilder(this.db, options) + searchStatisticsV3(options: AssetSearchBuilderV3Options, scope: AssetSearchScope) { + return searchAssetBuilder(this.db, options, scope) .select((qb) => qb.fn.countAll().as('total')) .executeTakeFirstOrThrow(); } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 9dfe3cfb72..297e8a36e9 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -246,6 +246,7 @@ describe(SearchService.name, () => { expect(mocks.search.searchSmartV3).toHaveBeenCalledWith( { size: 100, offset: 0 }, expect.objectContaining({ embedding: '[1, 2, 3]' }), + expect.objectContaining({ lockedOwnerId: expect.any(String) }), ); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 736e485feb..f9465e0206 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -22,6 +22,7 @@ import { StatisticsSearchDto, } from 'src/dtos/search.dto'; import { AssetOrder, AssetVisibility, Permission } from 'src/enum'; +import { AssetSearchScope } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { requireElevatedPermission } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; @@ -31,7 +32,6 @@ import { applyLockedVisibilityPolicy, collectFilterIds, hasTopLevelPositiveIdsConstraint, - isLockedOnlyFilter, } from 'src/utils/search-filter'; @Injectable() @@ -235,6 +235,71 @@ export class SearchService extends BaseService { } private async searchMetadataV3(auth: AuthDto, dto: MetadataSearchDto): Promise { + const { filter, scope } = await this.resolveSearchScopeV3(auth, dto); + + const { offset } = decodeSearchCursor(dto.cursor); + const size = dto.size; + const { hasNextPage, items } = await this.searchRepository.searchMetadataV3( + { size, offset }, + { + filter, + withExif: dto.withExif, + withPeople: dto.withPeople, + withStacked: dto.withStacked, + order: dto.orderBy, + }, + scope, + ); + + return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null }); + } + + private async searchStatisticsV3(auth: AuthDto, dto: StatisticsSearchDto): Promise { + const { filter, scope } = await this.resolveSearchScopeV3(auth, dto); + return this.searchRepository.searchStatisticsV3({ filter }, scope); + } + + private async searchRandomV3(auth: AuthDto, dto: RandomSearchDto): Promise { + const { filter, scope } = await this.resolveSearchScopeV3(auth, dto); + const items = await this.searchRepository.searchRandomV3( + dto.size, + { + filter, + withExif: dto.withExif, + withPeople: dto.withPeople, + withStacked: dto.withStacked, + }, + scope, + ); + 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 [{ filter, scope }, embedding] = await Promise.all([ + this.resolveSearchScopeV3(auth, dto), + this.resolveEmbedding(auth, dto, machineLearning), + ]); + + const { offset } = decodeSearchCursor(dto.cursor); + const size = dto.size; + const { hasNextPage, items } = await this.searchRepository.searchSmartV3( + { size, offset }, + { filter, withExif: dto.withExif, embedding }, + scope, + ); + + return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null }); + } + + private async resolveSearchScopeV3( + auth: AuthDto, + dto: { filter?: SearchFilter }, + ): Promise<{ filter: SearchFilter; scope: AssetSearchScope }> { const filter = dto.filter ?? {}; const effectiveFilter = applyLockedVisibilityPolicy(auth, filter); @@ -247,75 +312,11 @@ export class SearchService extends BaseService { const albumIds = collectFilterIds(filter, 'albumIds'); const [userIds] = await Promise.all([ - hasTopLevelAlbums ? undefined : this.getUserIdsToSearchV3(auth, filter), + hasTopLevelAlbums ? undefined : this.getUserIdsToSearch(auth), albumIds.length > 0 ? this.requireAccess({ auth, ids: albumIds, permission: Permission.AlbumRead }) : undefined, ]); - const { offset } = decodeSearchCursor(dto.cursor); - const size = dto.size; - 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, { - 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; - 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); + return { filter: effectiveFilter, scope: { userIds, lockedOwnerId: auth.user.id } }; } private async resolveEmbedding( diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0970839cb3..eb3dbb1106 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -38,7 +38,11 @@ import { ExifOrientation, SearchOrderField, } from 'src/enum'; -import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository'; +import { + AssetSearchBuilderOptions, + AssetSearchBuilderV3Options, + AssetSearchScope, +} from 'src/repositories/search.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types'; @@ -762,7 +766,7 @@ function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch // ordering is deliberately left to the caller so aggregate-only consumers (counts, stats) // can compose the same filters without stripping an order by -export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderV3Options) { +export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderV3Options, scope: AssetSearchScope) { const filter = options.filter ?? {}; return ( @@ -772,8 +776,9 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild // postgres eliminates the left join when no exif column is referenced, so unused joins are free .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .$if(!!options.withExif, (qb) => qb.select(selectExifInfo)) - .$if(!!options.userIds && options.userIds.length > 0, (qb) => - qb.where('asset.ownerId', '=', anyUuid(options.userIds!)), + .$if(!!scope.userIds && scope.userIds.length > 0, (qb) => qb.where('asset.ownerId', '=', anyUuid(scope.userIds!))) + .where((eb) => + eb.or([eb('asset.visibility', '!=', AssetVisibility.Locked), eb('asset.ownerId', '=', scope.lockedOwnerId)]), ) .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) @@ -809,86 +814,121 @@ export function withSearchOrder(qb: ReturnType, order ); } +const scopeExample: AssetSearchScope = { userIds: [DummyValue.UUID], lockedOwnerId: DummyValue.UUID }; + export const searchMetadataV3Examples: GenerateSqlQueries[] = [ - { name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] }, - { name: 'empty', params: [{ size: 100 }, {}] }, + { name: 'baseline', params: [{ size: 100 }, {}, scopeExample] }, + { name: 'empty', params: [{ size: 100 }, {}, { lockedOwnerId: DummyValue.UUID }] }, { name: 'or-exif-only', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }], + params: [ + { size: 100 }, + { + filter: { or: [{ city: { eq: DummyValue.STRING } }] }, + }, + scopeExample, + ], }, { name: 'string-eq-null', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }], + params: [{ size: 100 }, { filter: { city: { eq: null } } }, scopeExample], }, { name: 'string-pattern-like', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }], + params: [ + { size: 100 }, + { + filter: { description: { like: DummyValue.STRING } }, + }, + scopeExample, + ], }, { name: 'string-pattern-notLike', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }], + params: [ + { size: 100 }, + { + filter: { description: { notLike: DummyValue.STRING } }, + }, + scopeExample, + ], }, { name: 'string-pattern-startsWith', params: [ { size: 100 }, - { userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } }, + { + filter: { originalFileName: { startsWith: DummyValue.STRING } }, + }, + scopeExample, ], }, { name: 'string-similarity-ocr', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }], + params: [{ size: 100 }, { filter: { ocr: { matches: DummyValue.STRING } } }, scopeExample], }, { name: 'ids-any', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }], + params: [{ size: 100 }, { filter: { albumIds: { any: [DummyValue.UUID] } } }, scopeExample], }, { name: 'ids-all', params: [ { size: 100 }, - { userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + { + filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } }, + }, + scopeExample, ], }, { name: 'ids-all-single', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }], + params: [{ size: 100 }, { filter: { albumIds: { all: [DummyValue.UUID] } } }, scopeExample], }, { name: 'ids-none', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }], + params: [{ size: 100 }, { filter: { tagIds: { none: [DummyValue.UUID] } } }, scopeExample], }, { name: 'ids-tags-all', params: [ { size: 100 }, - { userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + { + filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } }, + }, + scopeExample, ], }, { name: 'has-albums-false', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }], + params: [{ size: 100 }, { filter: { hasAlbums: { eq: false } } }, scopeExample], }, { name: 'is-encoded', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }], + params: [{ size: 100 }, { filter: { isEncoded: { eq: true } } }, scopeExample], }, { name: 'number-range', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }], + params: [ + { size: 100 }, + { + filter: { fileSizeInBytes: { gte: 100, lte: 1000 } }, + }, + scopeExample, + ], }, { name: 'date-eq', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }], + params: [{ size: 100 }, { filter: { takenAt: { eq: DummyValue.DATE } } }, scopeExample], }, { name: 'date-range', params: [ { size: 100 }, { - userIds: [DummyValue.UUID], filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } }, }, + scopeExample, ], }, { @@ -896,10 +936,10 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [ params: [ { size: 100 }, { - userIds: [DummyValue.UUID], order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc }, withExif: false, }, + scopeExample, ], }, { @@ -907,10 +947,10 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [ params: [ { size: 100 }, { - userIds: [DummyValue.UUID], order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc }, withExif: true, }, + scopeExample, ], }, { @@ -918,11 +958,11 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [ params: [ { size: 100 }, { - userIds: [DummyValue.UUID], filter: { or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }], }, }, + scopeExample, ], }, { @@ -930,76 +970,73 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [ params: [ { size: 100 }, { - userIds: [DummyValue.UUID], filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }], }, }, + scopeExample, ], }, { name: 'cursor-offset', - params: [ - { size: 100, offset: 100 }, - { userIds: [DummyValue.UUID], filter: { isFavorite: { eq: true } } }, - ], + params: [{ size: 100, offset: 100 }, { filter: { isFavorite: { eq: true } } }, scopeExample], }, ]; export const searchRandomV3Examples: GenerateSqlQueries[] = [ - { name: 'baseline', params: [100, { userIds: [DummyValue.UUID] }] }, + { name: 'baseline', params: [100, {}, scopeExample] }, { name: 'with-filter', - params: [100, { userIds: [DummyValue.UUID], filter: { isFavorite: { eq: true } } }], + params: [100, { filter: { isFavorite: { eq: true } } }, scopeExample], }, ]; export const searchSmartV3Examples: GenerateSqlQueries[] = [ - { name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID], embedding: DummyValue.VECTOR }] }, + { + name: 'baseline', + params: [{ size: 100 }, { embedding: DummyValue.VECTOR }, scopeExample], + }, { name: 'with-filter', params: [ { size: 100 }, { - userIds: [DummyValue.UUID], embedding: DummyValue.VECTOR, filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } }, }, + scopeExample, ], }, { name: 'cursor-offset', - params: [ - { size: 100, offset: 100 }, - { userIds: [DummyValue.UUID], embedding: DummyValue.VECTOR }, - ], + params: [{ size: 100, offset: 100 }, { embedding: DummyValue.VECTOR }, scopeExample], }, ]; export const searchStatisticsV3Examples: GenerateSqlQueries[] = [ - { name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] }, + { name: 'baseline', params: [{}, scopeExample] }, { name: 'with-filter', params: [ { - userIds: [DummyValue.UUID], filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, fileSizeInBytes: { gte: 100 }, }, }, + scopeExample, ], }, { name: 'with-or', params: [ { - userIds: [DummyValue.UUID], filter: { or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }], }, }, + scopeExample, ], }, ]; diff --git a/server/src/utils/search-filter.spec.ts b/server/src/utils/search-filter.spec.ts index 40769be826..caa53712a0 100644 --- a/server/src/utils/search-filter.spec.ts +++ b/server/src/utils/search-filter.spec.ts @@ -5,7 +5,6 @@ import { applyLockedVisibilityPolicy, collectFilterIds, hasTopLevelPositiveIdsConstraint, - isLockedOnlyFilter, } from 'src/utils/search-filter'; import { AuthFactory } from 'test/factories/auth.factory'; import { describe, expect, it } from 'vitest'; @@ -67,25 +66,6 @@ describe(applyLockedVisibilityPolicy.name, () => { }); }); -describe(isLockedOnlyFilter.name, () => { - it('should detect provably locked-only filters', () => { - expect(isLockedOnlyFilter({ visibility: { eq: AssetVisibility.Locked } })).toBe(true); - expect(isLockedOnlyFilter({ visibility: { in: [AssetVisibility.Locked] } })).toBe(true); - expect( - isLockedOnlyFilter({ - visibility: { notIn: [AssetVisibility.Timeline, AssetVisibility.Archive, AssetVisibility.Hidden] }, - }), - ).toBe(true); - }); - - it('should not match other filters', () => { - expect(isLockedOnlyFilter({ visibility: { ne: AssetVisibility.Locked } })).toBe(false); - expect(isLockedOnlyFilter({ visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } })).toBe(false); - expect(isLockedOnlyFilter({ or: [{ visibility: { eq: AssetVisibility.Locked } }] })).toBe(false); - expect(isLockedOnlyFilter({})).toBe(false); - }); -}); - describe(collectFilterIds.name, () => { it('should union and dedupe ids across operators and branches', () => { const [albumA, albumB, albumC] = [ diff --git a/server/src/utils/search-filter.ts b/server/src/utils/search-filter.ts index 6715b6c021..2fbce52abd 100644 --- a/server/src/utils/search-filter.ts +++ b/server/src/utils/search-filter.ts @@ -18,9 +18,6 @@ const canMatch = (condition: EnumCondition, value: T): boolean => (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. */ @@ -54,10 +51,6 @@ 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(); diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 6d14a5d225..668ee58515 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -331,6 +331,25 @@ describe(SearchService.name, () => { expect(response.assets.items).toEqual([expect.objectContaining({ id: ownLocked.id })]); }); + it('should never return partner locked assets, even for locked-matching mixed filters', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id }); + const { asset: ownLocked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked }); + const { asset: partnerTimeline } = await ctx.newAsset({ ownerId: partner.id }); + await ctx.newAsset({ ownerId: partner.id, visibility: AssetVisibility.Locked }); + + const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } }); + const response = await sut.searchMetadata(auth, { + size: 250, + filter: { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } }, + }); + + const ids = response.assets.items.map(({ id }) => id); + expect(ids.toSorted()).toEqual([ownLocked.id, partnerTimeline.id].toSorted()); + }); + it('should paginate with an opaque cursor', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); @@ -385,13 +404,14 @@ describe(SearchService.name, () => { assetIds.push(asset.id); } - const options = { filter: {}, userIds: [user.id], embedding: unitVector(0) }; - const firstPage = await searchRepository.searchSmartV3({ size: 2 }, options); + const options = { filter: {}, embedding: unitVector(0) }; + const scope = { userIds: [user.id], lockedOwnerId: user.id }; + const firstPage = await searchRepository.searchSmartV3({ size: 2 }, options, scope); expect(firstPage.items.length).toBe(2); expect(firstPage.items[0].id).toBe(assetIds[0]); expect(firstPage.hasNextPage).toBe(true); - const secondPage = await searchRepository.searchSmartV3({ size: 2, offset: 2 }, options); + const secondPage = await searchRepository.searchSmartV3({ size: 2, offset: 2 }, options, scope); expect(secondPage.items.length).toBe(1); expect(secondPage.hasNextPage).toBe(false); });