From 5a895642708a75db9840ab9e585f488a2a09a2aa Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Wed, 27 May 2026 13:47:36 +0200 Subject: [PATCH] fix: strip metadata from timeline responses for shared links without exif sharing --- .../model/time_bucket_asset_response_dto.dart | 2 -- open-api/immich-openapi-specs.json | 2 -- packages/sdk/src/fetch-client.ts | 4 +-- server/src/dtos/time-bucket.dto.ts | 4 +-- server/src/queries/asset.repository.sql | 8 ++--- server/src/queries/search.repository.sql | 2 +- server/src/repositories/asset.repository.ts | 13 +++++--- server/src/services/timeline.service.ts | 4 +++ .../specs/services/timeline.service.spec.ts | 31 ++++++++++++++++++- .../timeline-manager/timeline-month.svelte.ts | 4 +-- web/src/test-data/factories/asset-factory.ts | 4 +-- 11 files changed, 56 insertions(+), 22 deletions(-) diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart index 45e793e9e3..32e08a9ea0 100644 --- a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -276,8 +276,6 @@ class TimeBucketAssetResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'city', - 'country', 'createdAt', 'duration', 'fileCreatedAt', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 357d76824a..9fda205b9a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -25324,8 +25324,6 @@ } }, "required": [ - "city", - "country", "createdAt", "duration", "fileCreatedAt", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 22fa8100d1..3f328088ee 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2612,9 +2612,9 @@ export type TagUpdateDto = { }; export type TimeBucketAssetResponseDto = { /** Array of city names extracted from EXIF GPS data */ - city: (string | null)[]; + city?: (string | null)[]; /** Array of country names extracted from EXIF GPS data */ - country: (string | null)[]; + country?: (string | null)[]; /** Array of UTC timestamps when each asset was originally uploaded to Immich */ createdAt: string[]; /** Array of video/gif durations in milliseconds (null for static images) */ diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index f63860a1df..a2ee06c878 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -107,8 +107,8 @@ const TimeBucketAssetResponseSchema = z livePhotoVideoId: z .array(z.string().nullable()) .describe('Array of live photo video asset IDs (null for non-live photos)'), - city: z.array(z.string().nullable()).describe('Array of city names extracted from EXIF GPS data'), - country: z.array(z.string().nullable()).describe('Array of country names extracted from EXIF GPS data'), + city: z.array(z.string().nullable()).optional().describe('Array of city names extracted from EXIF GPS data'), + country: z.array(z.string().nullable()).optional().describe('Array of country names extracted from EXIF GPS data'), latitude: z .array(z.number().nullable()) .optional() diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 4d90bbf0d5..819ec4f838 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -384,8 +384,6 @@ with asset."fileCreatedAt" at time zone 'utc' as "fileCreatedAt", asset."createdAt" at time zone 'utc' as "createdAt", encode("asset"."thumbhash", 'base64') as "thumbhash", - "asset_exif"."city", - "asset_exif"."country", "asset_exif"."projectionType", coalesce( case @@ -398,6 +396,8 @@ with end, 1 ) as "ratio", + "asset_exif"."city", + "asset_exif"."country", "stack" from "asset" @@ -432,8 +432,6 @@ with ), "agg" as ( select - coalesce(array_agg("city"), '{}') as "city", - coalesce(array_agg("country"), '{}') as "country", coalesce(array_agg("duration"), '{}') as "duration", coalesce(array_agg("id"), '{}') as "id", coalesce(array_agg("visibility"), '{}') as "visibility", @@ -449,6 +447,8 @@ with coalesce(array_agg("ratio"), '{}') as "ratio", coalesce(array_agg("status"), '{}') as "status", coalesce(array_agg("thumbhash"), '{}') as "thumbhash", + coalesce(array_agg("city"), '{}') as "city", + coalesce(array_agg("country"), '{}') as "country", coalesce(json_agg("stack"), '[]') as "stack" from "cte" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 3e75d88af8..818884b633 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -134,7 +134,7 @@ from "cte" where "cte"."distance" <= $4 -commit +rollback -- SearchRepository.searchPlaces select diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b144666773..5488f747ad 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -786,8 +786,6 @@ export class AssetRepository { sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'), sql`asset."createdAt" at time zone 'utc'`.as('createdAt'), eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'), - 'asset_exif.city', - 'asset_exif.country', 'asset_exif.projectionType', eb.fn .coalesce( @@ -801,6 +799,9 @@ export class AssetRepository { ) .as('ratio'), ]) + .$if(!auth.sharedLink || auth.sharedLink.showExif, (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) @@ -875,8 +876,6 @@ export class AssetRepository { qb .selectFrom('cte') .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'), eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'), eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'), eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'), @@ -894,6 +893,12 @@ 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) => + 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'), + ]), + ) .$if(!!options.withCoordinates, (qb) => qb.select((eb) => [ eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'), diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index b840883fa9..7e9013dc01 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -66,6 +66,10 @@ export class TimelineService extends BaseService { await this.requireAccess({ auth, permission: Permission.TagRead, ids: [dto.tagId] }); } + if (auth.sharedLink && !auth.sharedLink.showExif) { + dto.withCoordinates = false; + } + if (dto.withPartners) { const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; diff --git a/server/test/medium/specs/services/timeline.service.spec.ts b/server/test/medium/specs/services/timeline.service.spec.ts index 092a523010..cb696366d0 100644 --- a/server/test/medium/specs/services/timeline.service.spec.ts +++ b/server/test/medium/specs/services/timeline.service.spec.ts @@ -1,10 +1,11 @@ import { BadRequestException } from '@nestjs/common'; import { Kysely } from 'kysely'; -import { AssetVisibility } from 'src/enum'; +import { AssetVisibility, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { DB } from 'src/schema'; import { TimelineService } from 'src/services/timeline.service'; import { newMediumService } from 'test/medium.factory'; @@ -207,4 +208,32 @@ describe(TimelineService.name, () => { expect(response2).toEqual(expect.objectContaining({ id: [asset2.id, asset1.id], isFavorite: [true, false] })); }); }); + + it('should strip geodata metadata if shared link without exif', async () => { + const { sut, ctx } = setup(); + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ + ownerId: user.id, + localDateTime: new Date('1970-02-12'), + deletedAt: new Date(), + }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + + const { id: sharedLinkId } = await sharedLinkRepo.create({ + allowUpload: false, + key: Buffer.from('123'), + type: SharedLinkType.Album, + userId: user.id, + albumId: album.id, + }); + + await ctx.newExif({ assetId: asset.id, city: 'Austin', country: 'USA' }); + const auth = factory.auth({ sharedLink: { id: sharedLinkId, showExif: false } }); + const rawResponse = await sut.getTimeBucket(auth, { albumId: album.id, timeBucket: '1970-02-01', isTrashed: true }); + const response = JSON.parse(rawResponse); + expect(response).not.toEqual(expect.objectContaining({ city: expect.any(Array), country: expect.any(Array) })); + }); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts index bee53151f7..b6706e1ea1 100644 --- a/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts @@ -179,8 +179,8 @@ export class TimelineMonth { ); const timelineAsset: TimelineAsset = { - city: bucketAssets.city[i], - country: bucketAssets.country[i], + city: bucketAssets.city?.[i] ?? null, + country: bucketAssets.country?.[i] ?? null, duration: bucketAssets.duration[i], id: bucketAssets.id[i], visibility: bucketAssets.visibility[i], diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 17b53c7a80..ae3761c7ce 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -76,8 +76,8 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => { }; for (const asset of timelineAsset) { const fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt).toISO(); - bucketAssets.city.push(asset.city); - bucketAssets.country.push(asset.country); + bucketAssets.city?.push(asset.city); + bucketAssets.country?.push(asset.country); bucketAssets.duration.push(asset.duration!); bucketAssets.id.push(asset.id); bucketAssets.visibility.push(asset.visibility);