fix(server): apply shared link metadata visibility in asset mapper (#32561)

This commit is contained in:
bo0tzz
2026-10-19 20:31:26 +03:00
committed by Codex
parent 743e555a1b
commit c4c5b8ae3a
12 changed files with 58 additions and 48 deletions
@@ -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', () => {
+5 -6
View File
@@ -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<MapAsset>, 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<MapAsset>, 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<MapAsset>, 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,
+2
View File
@@ -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' }),
+3 -2
View File
@@ -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,
+3 -5
View File
@@ -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'),
+2 -2
View File
@@ -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<MapMarkerResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
if (auth.sharedLink && !auth.sharedLink.showExif) {
if (isMetadataHidden(auth)) {
return [];
}
+2 -3
View File
@@ -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({
+6 -2
View File
@@ -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]),
+1 -15
View File
@@ -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<AssetResponseDto> {
@@ -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);
});
+8 -8
View File
@@ -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<SharedLinkResponseDto> {
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<SharedLinkResponseDto> {
@@ -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);
+2 -2
View File
@@ -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;
}