From 82cad5a965fe49e724223e6ef181beb4c5f58fdd Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Sat, 27 Jun 2026 13:13:34 +0200 Subject: [PATCH] feat: honor album access permissions in search endpoints --- server/src/queries/search.repository.sql | 30 +++++++-------- server/src/utils/database.ts | 22 ++++++++++- .../specs/services/search.service.spec.ts | 38 +++++++++++++++++++ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 3e75d88af8..864d160e5e 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -8,9 +8,9 @@ from inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."visibility" = $1 - and "asset"."fileCreatedAt" >= $2 - and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) + and "asset"."ownerId" = any ($2::uuid[]) + and "asset"."fileCreatedAt" >= $3 + and "asset_exif"."lensModel" = $4 and "asset"."isFavorite" = $5 and "asset"."deletedAt" is null order by @@ -28,9 +28,9 @@ from inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."visibility" = $1 - and "asset"."fileCreatedAt" >= $2 - and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) + and "asset"."ownerId" = any ($2::uuid[]) + and "asset"."fileCreatedAt" >= $3 + and "asset_exif"."lensModel" = $4 and "asset"."isFavorite" = $5 and "asset"."deletedAt" is null @@ -42,9 +42,9 @@ from inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."visibility" = $1 - and "asset"."fileCreatedAt" >= $2 - and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) + and "asset"."ownerId" = any ($2::uuid[]) + and "asset"."fileCreatedAt" >= $3 + and "asset_exif"."lensModel" = $4 and "asset"."isFavorite" = $5 and "asset"."deletedAt" is null order by @@ -61,9 +61,9 @@ from inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."visibility" = $1 - and "asset"."fileCreatedAt" >= $2 - and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) + and "asset"."ownerId" = any ($2::uuid[]) + and "asset"."fileCreatedAt" >= $3 + and "asset_exif"."lensModel" = $4 and "asset"."isFavorite" = $5 and "asset"."deletedAt" is null and "asset_exif"."fileSizeInByte" > $6 @@ -84,9 +84,9 @@ from inner join "smart_search" on "asset"."id" = "smart_search"."assetId" where "asset"."visibility" = $1 - and "asset"."fileCreatedAt" >= $2 - and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) + and "asset"."ownerId" = any ($2::uuid[]) + and "asset"."fileCreatedAt" >= $3 + and "asset_exif"."lensModel" = $4 and "asset"."isFavorite" = $5 and "asset"."deletedAt" is null order by diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index a187c25913..54a9600948 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -380,6 +380,27 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .selectFrom('asset') .where('asset.visibility', '=', visibility) .$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!)) + .$if(!!options.userIds, (qb) => { + if (options.albumIds && options.albumIds.length > 0) { + return qb.where((eb) => + eb( + 'asset.ownerId', + 'in', + eb + .selectFrom('album_user') + .select('album_user.userId') + .where('album_user.albumId', 'in', (eb) => + eb + .selectFrom('album_user') + .select('album_user.albumId') + .whereRef('album_user.albumId', '=', anyUuid(options.albumIds!)) + .where('album_user.userId', '=', options.userIds![0]), + ), + ), + ); + } + return qb.where('asset.ownerId', '=', anyUuid(options.userIds!)); + }) .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(options.tagIds === null, (qb) => qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetId', '=', 'asset.id')))), @@ -431,7 +452,6 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!)) .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) - .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.encodedVideoPath, (qb) => qb .innerJoin('asset_file', (join) => diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 18e03b2e48..14d29186cd 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { SearchSuggestionType } from 'src/dtos/search.dto'; +import { AlbumUserRole } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; @@ -110,6 +111,43 @@ describe(SearchService.name, () => { }); }); + describe('albumIds option', () => { + it('should return assets from shared album', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + + const { asset } = await ctx.newAsset({ ownerId: otherUser.id }); + const { album } = await ctx.newAlbum({ ownerId: otherUser.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor }); + + const auth = factory.auth({ user: { id: user.id } }); + + const response = await sut.searchMetadata(auth, { albumIds: [album.id] }); + + expect(response.assets.items.length).toBe(1); + }); + + it('should not return assets for album user is not in when partner sharing is enabled', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + + await ctx.newPartner({ sharedById: otherUser.id, sharedWithId: user.id }); + + const { asset } = await ctx.newAsset({ ownerId: otherUser.id }); + const { album } = await ctx.newAlbum({ ownerId: otherUser.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + + const auth = factory.auth({ user: { id: user.id } }); + + const response = await sut.searchMetadata(auth, { albumIds: [album.id] }); + + expect(response.assets.items.length).toBe(0); + }); + }); + describe('getSearchSuggestions', () => { it('should filter out empty search suggestions', async () => { const { sut, ctx } = setup();