fix(server): apply shared link metadata visibility in asset mapper (#32561)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user