From c4c5b8ae3a98de07e66b5e93a55daf2df72c0567 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 19 Oct 2026 18:34:22 +0200 Subject: [PATCH] fix(server): apply shared link metadata visibility in asset mapper (#32561) --- e2e/src/specs/server/api/search.e2e-spec.ts | 21 +++++++++++++++++++ server/src/dtos/asset-response.dto.ts | 11 +++++----- server/src/dtos/auth.dto.ts | 2 ++ server/src/dtos/shared-link.dto.ts | 5 +++-- server/src/repositories/asset.repository.ts | 8 +++---- server/src/services/album.service.ts | 4 ++-- server/src/services/asset-media.service.ts | 5 ++--- server/src/services/asset.service.spec.ts | 8 +++++-- server/src/services/asset.service.ts | 16 +------------- .../src/services/shared-link.service.spec.ts | 6 +++--- server/src/services/shared-link.service.ts | 16 +++++++------- server/src/services/timeline.service.ts | 4 ++-- 12 files changed, 58 insertions(+), 48 deletions(-) diff --git a/e2e/src/specs/server/api/search.e2e-spec.ts b/e2e/src/specs/server/api/search.e2e-spec.ts index e4616bd03..e34955595 100644 --- a/e2e/src/specs/server/api/search.e2e-spec.ts +++ b/e2e/src/specs/server/api/search.e2e-spec.ts @@ -384,6 +384,27 @@ describe('/search', () => { expect(status).toBe(200); expect(body.assets.items).toEqual([expect.objectContaining({ id: asset.id })]); }); + + it('should strip metadata for a shared link without metadata', 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, + showMetadata: false, + }); + + const { status, body } = await request(app) + .post(`/search/metadata?key=${sharedLink.key}`) + .send({ albumIds: [album.id], withExif: true }); + expect(status).toBe(200); + expect(body.assets.items).toHaveLength(1); + expect(body.assets.items[0]).toEqual(expect.objectContaining({ id: asset.id, hasMetadata: false })); + expect(body.assets.items[0]).not.toHaveProperty('exifInfo'); + expect(body.assets.items[0]).not.toHaveProperty('originalPath'); + expect(body.assets.items[0]).not.toHaveProperty('originalFileName'); + expect(body.assets.items[0]).not.toHaveProperty('ownerId'); + }); }); describe('POST /search/random', () => { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 78cb6eb2a..cb1c48b0b 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -4,7 +4,7 @@ import z from 'zod'; import type { MaybeDehydrated } from 'src/types.js'; import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database.js'; import { HistoryBuilder } from 'src/decorators.js'; -import { AuthDto } from 'src/dtos/auth.dto.js'; +import { AuthDto, isMetadataHidden } from 'src/dtos/auth.dto.js'; import { AssetEditActionItem } from 'src/dtos/editing.dto.js'; import { ExifResponseSchema, mapExif } from 'src/dtos/exif.dto.js'; import { PersonResponseDto, PersonResponseSchema, mapPerson } from 'src/dtos/person.dto.js'; @@ -157,7 +157,6 @@ export type MapAsset = { }; export type AssetMapOptions = { - stripMetadata?: boolean; withStack?: boolean; auth?: AuthDto; }; @@ -191,9 +190,9 @@ const mapStack = (entity: { stack?: Stack | null }) => { }; export function mapAsset(entity: MaybeDehydrated, options: AssetMapOptions = {}): AssetResponseDto { - const { stripMetadata = false, withStack = false } = options; + const { withStack = false, auth } = options; - if (stripMetadata) { + if (isMetadataHidden(auth)) { const sanitizedAssetResponse: SanitizedAssetResponseDto = { id: entity.id, type: entity.type, @@ -213,7 +212,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt id: entity.id, createdAt: asDateTimeString(entity.createdAt), ownerId: entity.ownerId, - owner: entity.owner ? mapUser(entity.owner) : undefined, + owner: entity.owner && !auth?.sharedLink ? mapUser(entity.owner) : undefined, libraryId: entity.libraryId, type: entity.type, originalPath: entity.originalPath, @@ -232,7 +231,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map((tag) => mapTag(tag)), - people: peopleFromFaces(entity.faces), + people: auth?.sharedLink ? [] : peopleFromFaces(entity.faces), checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 14fb7ef8e..fa4c3f4ae 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -19,6 +19,8 @@ export type AuthDto = { session?: AuthSession; }; +export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.sharedLink.showExif; + const LoginCredentialSchema = z .object({ email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }), diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index 9e35b4ac6..f5239bd09 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -4,6 +4,7 @@ import { SharedLink } from 'src/database.js'; import { HistoryBuilder } from 'src/decorators.js'; import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto.js'; import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto.js'; +import { AuthDto } from 'src/dtos/auth.dto.js'; import { SharedLinkType, SharedLinkTypeSchema } from 'src/enum.js'; import { isoDatetimeToDate } from 'src/validation.js'; @@ -100,7 +101,7 @@ export class SharedLinkEditDto extends createZodDto(SharedLinkEditSchema) {} export class SharedLinkLoginDto extends createZodDto(SharedLinkLoginSchema) {} export class SharedLinkResponseDto extends createZodDto(SharedLinkResponseSchema) {} -export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto { +export function mapSharedLink(sharedLink: SharedLink, { auth }: { auth: AuthDto }): SharedLinkResponseDto { const assets = sharedLink.assets || []; const response = { @@ -112,7 +113,7 @@ export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetad type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })), + assets: assets.map((asset) => mapAsset(asset, { auth })), album: sharedLink.album ? mapAlbum(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 13fc820d0..e70f37cd1 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -14,9 +14,9 @@ import { import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { isEmpty, isUndefined, omitBy } from 'lodash-es'; import { InjectKysely } from 'nestjs-kysely'; -import type { AuthDto } from 'src/dtos/auth.dto.js'; import { LockableProperty, Stack } from 'src/database.js'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators.js'; +import { type AuthDto, isMetadataHidden } from 'src/dtos/auth.dto.js'; import { AssetFileType, AssetOrder, @@ -852,9 +852,7 @@ export class AssetRepository { ) .as('ratio'), ]) - .$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) => - qb.select(['asset_exif.city', 'asset_exif.country']), - ) + .$if(!isMetadataHidden(auth), (qb) => qb.select(['asset_exif.city', 'asset_exif.country'])) .$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude'])) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility === undefined, withDefaultVisibility) @@ -952,7 +950,7 @@ export class AssetRepository { eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'), eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'), ]) - .$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) => + .$if(!isMetadataHidden(auth), (qb) => qb.select((eb) => [ eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'), eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'), diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index c4a69e825..c18ff5f8e 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -13,7 +13,7 @@ import { mapAlbum, } from 'src/dtos/album.dto.js'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto.js'; -import { AuthDto } from 'src/dtos/auth.dto.js'; +import { AuthDto, isMetadataHidden } from 'src/dtos/auth.dto.js'; import { MapMarkerResponseDto } from 'src/dtos/map.dto.js'; import { AlbumUserRole, Permission } from 'src/enum.js'; import { AlbumAssetCount } from 'src/repositories/album.repository.js'; @@ -91,7 +91,7 @@ export class AlbumService extends BaseService { async getMapMarkers(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] }); - if (auth.sharedLink && !auth.sharedLink.showExif) { + if (isMetadataHidden(auth)) { return []; } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 1b28f5a4a..7b4193065 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -18,7 +18,7 @@ import { UploadFieldName, } from 'src/dtos/asset-media.dto.js'; import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto.js'; -import { AuthDto } from 'src/dtos/auth.dto.js'; +import { AuthDto, isMetadataHidden } from 'src/dtos/auth.dto.js'; import { AssetFileType, AssetVisibility, @@ -285,8 +285,7 @@ export class AssetMediaService extends BaseService { throw new NotFoundException('Asset media not found'); } - const fileNameBase = - auth.sharedLink && !auth.sharedLink.showExif ? id : getFileNameWithoutExtension(originalFileName); + const fileNameBase = isMetadataHidden(auth) ? id : getFileNameWithoutExtension(originalFileName); const fileName = `${fileNameBase}_${size}${getFilenameExtension(path)}`; return new ImmichFileResponse({ diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index edbad1223..43fb6cec9 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -93,12 +93,16 @@ describe(AssetService.name, () => { }); it('should allow shared link access', async () => { - const asset = AssetFactory.create(); + const asset = AssetFactory.from() + .owner() + .face({}, (face) => face.person()) + .build(); mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); - await sut.get(authStub.adminSharedLink, asset.id); + const result = await sut.get(authStub.adminSharedLink, asset.id); + expect(result).toMatchObject({ id: asset.id, hasMetadata: true, people: [], owner: undefined }); expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set([asset.id]), diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index df0ede47f..60ea58904 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -80,21 +80,7 @@ export class AssetService extends BaseService { throw new BadRequestException('Asset not found'); } - if (auth.sharedLink && !auth.sharedLink.showExif) { - return mapAsset(asset, { stripMetadata: true, withStack: true, auth }); - } - - const data = mapAsset(asset, { withStack: true, auth }); - - if (auth.sharedLink) { - delete data.owner; - } - - if (auth.sharedLink) { - data.people = []; - } - - return data; + return mapAsset(asset, { withStack: true, auth }); } async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index c32a2294e..d773d7b9c 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -30,7 +30,7 @@ describe(SharedLinkService.name, () => { mocks.sharedLink.getAll.mockResolvedValue([getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)]); await expect(sut.getAll(authStub.user1, {})).resolves.toEqual( [getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)].map((link) => - mapSharedLink(link, { stripAssetMetadata: false }), + mapSharedLink(link, { auth: authStub.user1 }), ), ); expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); @@ -48,7 +48,7 @@ describe(SharedLinkService.name, () => { const sharedLink = SharedLinkFactory.create(); mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); await expect(sut.getMine(authDto, [])).resolves.toEqual( - mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: false }), + mapSharedLink(getForSharedLink(sharedLink), { auth: authDto }), ); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); @@ -107,7 +107,7 @@ describe(SharedLinkService.name, () => { const sharedLink = SharedLinkFactory.create(); mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); await expect(sut.get(authStub.user1, sharedLink.id)).resolves.toEqual( - mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: true }), + mapSharedLink(getForSharedLink(sharedLink), { auth: authStub.user1 }), ); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 86a04d8a9..c1baaab1f 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -23,7 +23,7 @@ export class SharedLinkService extends BaseService { return this.sharedLinkRepository .getAll({ userId: auth.user.id, id, albumId }) - .then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false }))); + .then((links) => links.map((link) => mapSharedLink(link, { auth }))); } async login(auth: AuthDto, dto: SharedLinkLoginDto) { @@ -43,7 +43,7 @@ export class SharedLinkService extends BaseService { } return { - sharedLink: await this.mapWithAlbumMetadata(sharedLink, { stripAssetMetadata: !sharedLink.showExif }), + sharedLink: await this.mapWithAlbumMetadata(sharedLink, { auth }), token: this.asToken({ id, password }), }; } @@ -60,12 +60,12 @@ export class SharedLinkService extends BaseService { throw new UnauthorizedException('Password required'); } - return this.mapWithAlbumMetadata(sharedLink, { stripAssetMetadata: !sharedLink.showExif }); + return this.mapWithAlbumMetadata(sharedLink, { auth }); } async get(auth: AuthDto, id: string): Promise { const sharedLink = await this.findOrFail(auth.user.id, id); - return this.mapWithAlbumMetadata(sharedLink, { stripAssetMetadata: false }); + return this.mapWithAlbumMetadata(sharedLink, { auth }); } async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise { @@ -105,7 +105,7 @@ export class SharedLinkService extends BaseService { slug: dto.slug || null, }); - return mapSharedLink(sharedLink, { stripAssetMetadata: false }); + return mapSharedLink(sharedLink, { auth }); } catch (error) { this.handleError(error); } @@ -133,7 +133,7 @@ export class SharedLinkService extends BaseService { showExif: dto.showMetadata, slug: dto.slug || null, }); - return mapSharedLink(sharedLink, { stripAssetMetadata: false }); + return mapSharedLink(sharedLink, { auth }); } catch (error) { this.handleError(error); } @@ -235,8 +235,8 @@ export class SharedLinkService extends BaseService { }; } - private async mapWithAlbumMetadata(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }) { - const response = mapSharedLink(sharedLink, options); + private async mapWithAlbumMetadata(sharedLink: SharedLink, { auth }: { auth: AuthDto }) { + const response = mapSharedLink(sharedLink, { auth }); if (response.album) { const [metadata] = await this.albumRepository.getMetadataForIds([response.album.id]); response.album.startDate = asDateTimeString(metadata?.startDate ?? undefined); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 1be8080d2..44aa6366b 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { AuthDto } from 'src/dtos/auth.dto.js'; +import { AuthDto, isMetadataHidden } from 'src/dtos/auth.dto.js'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto.js'; import { AssetVisibility, Permission } from 'src/enum.js'; import { TimeBucketOptions } from 'src/repositories/asset.repository.js'; @@ -69,7 +69,7 @@ export class TimelineService extends BaseService { await this.requireAccess({ auth, permission: Permission.TagRead, ids: [dto.tagId] }); } - if (auth.sharedLink && !auth.sharedLink.showExif) { + if (isMetadataHidden(auth)) { dto.withCoordinates = false; }