diff --git a/e2e/src/specs/server/api/search.e2e-spec.ts b/e2e/src/specs/server/api/search.e2e-spec.ts index 0b86053f78..753a23d442 100644 --- a/e2e/src/specs/server/api/search.e2e-spec.ts +++ b/e2e/src/specs/server/api/search.e2e-spec.ts @@ -4,6 +4,7 @@ import { AssetVisibility, deleteAssets, LoginResponseDto, + SharedLinkType, updateAsset, } from '@immich/sdk'; import { DateTime } from 'luxon'; @@ -357,6 +358,32 @@ describe('/search', () => { expect(body.assets.items).toHaveLength(assets.length); }); } + + it('should reject shared link access without an album filter', async () => { + const album = await utils.createAlbum(admin.accessToken, { albumName: 'foo' }); + const sharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Album, + albumId: album.id, + }); + const { status, body } = await request(app).post(`/search/metadata?key=${sharedLink.key}`).send({}); + expect(status).toBe(400); + expect(body).toEqual({ message: 'Shared link access is only allowed in combination with an albumIds filter' }); + }); + + it('should allow shared link access for albums', async () => { + const asset = await utils.createAsset(admin.accessToken); + const album = await utils.createAlbum(admin.accessToken, { albumName: 'foo', assetIds: [asset.id] }); + const sharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Album, + albumId: album.id, + }); + + const { status, body } = await request(app) + .post(`/search/metadata?key=${sharedLink.key}`) + .send({ albumIds: [album.id] }); + expect(status).toBe(200); + expect(body.assets.items).toEqual([expect.objectContaining({ id: asset.id })]); + }); }); describe('POST /search/random', () => { diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 0118cabdba..9957949812 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -302,7 +302,11 @@ class SearchApi { /// Parameters: /// /// * [MetadataSearchDto] metadataSearchDto (required): - Future searchAssetsWithHttpInfo(MetadataSearchDto metadataSearchDto, { Future? abortTrigger, }) async { + /// + /// * [String] key: + /// + /// * [String] slug: + Future searchAssetsWithHttpInfo(MetadataSearchDto metadataSearchDto, { String? key, String? slug, Future? abortTrigger, }) async { // ignore: prefer_const_declarations final apiPath = r'/search/metadata'; @@ -313,6 +317,13 @@ class SearchApi { final headerParams = {}; final formParams = {}; + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + const contentTypes = ['application/json']; @@ -335,8 +346,12 @@ class SearchApi { /// Parameters: /// /// * [MetadataSearchDto] metadataSearchDto (required): - Future searchAssets(MetadataSearchDto metadataSearchDto, { Future? abortTrigger, }) async { - final response = await searchAssetsWithHttpInfo(metadataSearchDto, abortTrigger: abortTrigger,); + /// + /// * [String] key: + /// + /// * [String] slug: + Future searchAssets(MetadataSearchDto metadataSearchDto, { String? key, String? slug, Future? abortTrigger, }) async { + final response = await searchAssetsWithHttpInfo(metadataSearchDto, key: key, slug: slug, abortTrigger: abortTrigger,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e51c74c39d..07f20a80b1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10552,7 +10552,24 @@ "post": { "description": "Search for assets based on various metadata criteria.", "operationId": "searchAssets", - "parameters": [], + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 209a7b7f66..8ed45f7806 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -5764,13 +5764,18 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat /** * Search assets by metadata */ -export function searchAssets({ metadataSearchDto }: { +export function searchAssets({ key, slug, metadataSearchDto }: { + key?: string; + slug?: string; metadataSearchDto: MetadataSearchDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: SearchResponseDto; - }>("/search/metadata", oazapfts.json({ + }>(`/search/metadata${QS.query(QS.explode({ + key, + slug + }))}`, oazapfts.json({ ...opts, method: "POST", body: metadataSearchDto diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 439a7a5118..30feecbe10 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -28,7 +28,7 @@ export class SearchController { constructor(private service: SearchService) {} @Post('metadata') - @Authenticated({ permission: Permission.AssetRead }) + @Authenticated({ permission: Permission.AssetRead, sharedLink: true }) @HttpCode(HttpStatus.OK) @Endpoint({ summary: 'Search assets by metadata', diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index f7094eba91..46cbefa35c 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -77,6 +77,8 @@ export class SearchService extends BaseService { if (dto.albumIds && dto.albumIds.length > 0) { await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead }); + } else if (auth.sharedLink) { + throw new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'); } else { userIds = await this.getUserIdsToSearch(auth, dto.visibility); }