1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-04 02:46:47 +01:00

fix(server,web): correctly remove metadata from shared links (#4464)

* wip: strip metadata

* fix: authenticate time buckets

* hide detail panel

* fix tests

* fix lint

* add e2e tests

* chore: open api

* fix web compilation error

* feat: test with asset with gps position

* fix: only import fs.promises.cp

* fix: cleanup mapasset

* fix: format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jonathan Jogenfors 2023-10-14 03:46:30 +02:00 committed by GitHub
parent 4a9f58bf9b
commit dadcf49eca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 283 additions and 113 deletions

View file

@ -640,6 +640,12 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
'fileModifiedAt': string; 'fileModifiedAt': string;
/**
*
* @type {boolean}
* @memberof AssetResponseDto
*/
'hasMetadata': boolean;
/** /**
* *
* @type {string} * @type {string}
@ -749,7 +755,7 @@ export interface AssetResponseDto {
*/ */
'tags'?: Array<TagResponseDto>; 'tags'?: Array<TagResponseDto>;
/** /**
* base64 encoded thumbhash *
* @type {string} * @type {string}
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
@ -2882,7 +2888,7 @@ export interface SharedLinkCreateDto {
* @type {boolean} * @type {boolean}
* @memberof SharedLinkCreateDto * @memberof SharedLinkCreateDto
*/ */
'showExif'?: boolean; 'showMetadata'?: boolean;
/** /**
* *
* @type {SharedLinkType} * @type {SharedLinkType}
@ -2927,7 +2933,7 @@ export interface SharedLinkEditDto {
* @type {boolean} * @type {boolean}
* @memberof SharedLinkEditDto * @memberof SharedLinkEditDto
*/ */
'showExif'?: boolean; 'showMetadata'?: boolean;
} }
/** /**
* *
@ -2994,7 +3000,7 @@ export interface SharedLinkResponseDto {
* @type {boolean} * @type {boolean}
* @memberof SharedLinkResponseDto * @memberof SharedLinkResponseDto
*/ */
'showExif': boolean; 'showMetadata': boolean;
/** /**
* *
* @type {SharedLinkType} * @type {SharedLinkType}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5770,6 +5770,9 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"hasMetadata": {
"type": "boolean"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -5833,7 +5836,6 @@
"type": "array" "type": "array"
}, },
"thumbhash": { "thumbhash": {
"description": "base64 encoded thumbhash",
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
@ -5847,7 +5849,6 @@
}, },
"required": [ "required": [
"type", "type",
"id",
"deviceAssetId", "deviceAssetId",
"deviceId", "deviceId",
"ownerId", "ownerId",
@ -5855,19 +5856,21 @@
"originalPath", "originalPath",
"originalFileName", "originalFileName",
"resized", "resized",
"thumbhash",
"fileCreatedAt", "fileCreatedAt",
"fileModifiedAt", "fileModifiedAt",
"updatedAt", "updatedAt",
"isFavorite", "isFavorite",
"isArchived", "isArchived",
"isTrashed", "isTrashed",
"localDateTime",
"isOffline", "isOffline",
"isExternal", "isExternal",
"isReadOnly", "isReadOnly",
"checksum",
"id",
"thumbhash",
"localDateTime",
"duration", "duration",
"checksum" "hasMetadata"
], ],
"type": "object" "type": "object"
}, },
@ -7599,7 +7602,7 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"showExif": { "showMetadata": {
"default": true, "default": true,
"type": "boolean" "type": "boolean"
}, },
@ -7628,7 +7631,7 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"showExif": { "showMetadata": {
"type": "boolean" "type": "boolean"
} }
}, },
@ -7670,7 +7673,7 @@
"key": { "key": {
"type": "string" "type": "string"
}, },
"showExif": { "showMetadata": {
"type": "boolean" "type": "boolean"
}, },
"type": { "type": {
@ -7691,7 +7694,7 @@
"assets", "assets",
"allowUpload", "allowUpload",
"allowDownload", "allowDownload",
"showExif" "showMetadata"
], ],
"type": "object" "type": "object"
}, },

View file

@ -47,6 +47,7 @@ import {
BulkIdsDto, BulkIdsDto,
MapMarkerResponseDto, MapMarkerResponseDto,
MemoryLaneResponseDto, MemoryLaneResponseDto,
SanitizedAssetResponseDto,
TimeBucketResponseDto, TimeBucketResponseDto,
mapAsset, mapAsset,
} from './response-dto'; } from './response-dto';
@ -198,10 +199,17 @@ export class AssetService {
return this.assetRepository.getTimeBuckets(dto); return this.assetRepository.getTimeBuckets(dto);
} }
async getByTimeBucket(authUser: AuthUserDto, dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> { async getByTimeBucket(
authUser: AuthUserDto,
dto: TimeBucketAssetDto,
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
await this.timeBucketChecks(authUser, dto); await this.timeBucketChecks(authUser, dto);
const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto); const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto);
return assets.map(mapAsset); if (authUser.isShowMetadata) {
return assets.map((asset) => mapAsset(asset));
} else {
return assets.map((asset) => mapAsset(asset, true));
}
} }
async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> { async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {

View file

@ -6,43 +6,62 @@ import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.
import { ExifResponseDto, mapExif } from './exif-response.dto'; import { ExifResponseDto, mapExif } from './exif-response.dto';
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto'; import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
export class AssetResponseDto { export class SanitizedAssetResponseDto {
id!: string; id!: string;
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type!: AssetType;
thumbhash!: string | null;
resized!: boolean;
localDateTime!: Date;
duration!: string;
livePhotoVideoId?: string | null;
hasMetadata!: boolean;
}
export class AssetResponseDto extends SanitizedAssetResponseDto {
deviceAssetId!: string; deviceAssetId!: string;
deviceId!: string; deviceId!: string;
ownerId!: string; ownerId!: string;
owner?: UserResponseDto; owner?: UserResponseDto;
libraryId!: string; libraryId!: string;
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type!: AssetType;
originalPath!: string; originalPath!: string;
originalFileName!: string; originalFileName!: string;
resized!: boolean; resized!: boolean;
/**base64 encoded thumbhash */
thumbhash!: string | null;
fileCreatedAt!: Date; fileCreatedAt!: Date;
fileModifiedAt!: Date; fileModifiedAt!: Date;
updatedAt!: Date; updatedAt!: Date;
isFavorite!: boolean; isFavorite!: boolean;
isArchived!: boolean; isArchived!: boolean;
isTrashed!: boolean; isTrashed!: boolean;
localDateTime!: Date;
isOffline!: boolean; isOffline!: boolean;
isExternal!: boolean; isExternal!: boolean;
isReadOnly!: boolean; isReadOnly!: boolean;
duration!: string;
exifInfo?: ExifResponseDto; exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto; smartInfo?: SmartInfoResponseDto;
livePhotoVideoId?: string | null;
tags?: TagResponseDto[]; tags?: TagResponseDto[];
people?: PersonResponseDto[]; people?: PersonResponseDto[];
/**base64 encoded sha1 hash */ /**base64 encoded sha1 hash */
checksum!: string; checksum!: string;
} }
function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto { export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
thumbhash: entity.thumbhash?.toString('base64') ?? null,
localDateTime: entity.localDateTime,
resized: !!entity.resizePath,
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
};
if (stripMetadata) {
return sanitizedAssetResponse as AssetResponseDto;
}
return { return {
...sanitizedAssetResponse,
id: entity.id, id: entity.id,
deviceAssetId: entity.deviceAssetId, deviceAssetId: entity.deviceAssetId,
ownerId: entity.ownerId, ownerId: entity.ownerId,
@ -62,7 +81,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
isArchived: entity.isArchived, isArchived: entity.isArchived,
isTrashed: !!entity.deletedAt, isTrashed: !!entity.deletedAt,
duration: entity.duration ?? '0:00:00.00000', duration: entity.duration ?? '0:00:00.00000',
exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined, exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId, livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag), tags: entity.tags?.map(mapTag),
@ -71,17 +90,10 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
isExternal: entity.isExternal, isExternal: entity.isExternal,
isOffline: entity.isOffline, isOffline: entity.isOffline,
isReadOnly: entity.isReadOnly, isReadOnly: entity.isReadOnly,
hasMetadata: true,
}; };
} }
export function mapAsset(entity: AssetEntity): AssetResponseDto {
return _map(entity, true);
}
export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
return _map(entity, false);
}
export class MemoryLaneResponseDto { export class MemoryLaneResponseDto {
title!: string; title!: string;
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];

View file

@ -52,3 +52,15 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
projectionType: entity.projectionType, projectionType: entity.projectionType,
}; };
} }
export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto {
return {
fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation,
dateTimeOriginal: entity.dateTimeOriginal,
timeZone: entity.timeZone,
projectionType: entity.projectionType,
exifImageWidth: entity.exifImageWidth,
exifImageHeight: entity.exifImageHeight,
};
}

View file

@ -380,7 +380,7 @@ export class AuthService {
sharedLinkId: link.id, sharedLinkId: link.id,
isAllowUpload: link.allowUpload, isAllowUpload: link.allowUpload,
isAllowDownload: link.allowDownload, isAllowDownload: link.allowDownload,
isShowExif: link.showExif, isShowMetadata: link.showExif,
}; };
} }
} }
@ -431,7 +431,7 @@ export class AuthService {
isPublicUser: false, isPublicUser: false,
isAllowUpload: true, isAllowUpload: true,
isAllowDownload: true, isAllowDownload: true,
isShowExif: true, isShowMetadata: true,
accessTokenId: token.id, accessTokenId: token.id,
}; };
} }

View file

@ -6,7 +6,7 @@ export class AuthUserDto {
sharedLinkId?: string; sharedLinkId?: string;
isAllowUpload?: boolean; isAllowUpload?: boolean;
isAllowDownload?: boolean; isAllowDownload?: boolean;
isShowExif?: boolean; isShowMetadata?: boolean;
accessTokenId?: string; accessTokenId?: string;
externalPath?: string | null; externalPath?: string | null;
} }

View file

@ -97,7 +97,7 @@ export class PersonService {
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> { async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
await this.access.requirePermission(authUser, Permission.PERSON_READ, id); await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
const assets = await this.repository.getAssets(id); const assets = await this.repository.getAssets(id);
return assets.map(mapAsset); return assets.map((asset) => mapAsset(asset));
} }
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {

View file

@ -154,7 +154,7 @@ export class SearchService {
items: assets.items items: assets.items
.map((item) => lookup[item.id]) .map((item) => lookup[item.id])
.filter((item) => !!item) .filter((item) => !!item)
.map(mapAsset), .map((asset) => mapAsset(asset)),
}, },
}; };
} }

View file

@ -2,7 +2,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import _ from 'lodash'; import _ from 'lodash';
import { AlbumResponseDto, mapAlbumWithoutAssets } from '../album'; import { AlbumResponseDto, mapAlbumWithoutAssets } from '../album';
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset'; import { AssetResponseDto, mapAsset } from '../asset';
export class SharedLinkResponseDto { export class SharedLinkResponseDto {
id!: string; id!: string;
@ -17,8 +17,9 @@ export class SharedLinkResponseDto {
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
album?: AlbumResponseDto; album?: AlbumResponseDto;
allowUpload!: boolean; allowUpload!: boolean;
allowDownload!: boolean; allowDownload!: boolean;
showExif!: boolean; showMetadata!: boolean;
} }
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
@ -35,15 +36,15 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
type: sharedLink.type, type: sharedLink.type,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: assets.map(mapAsset), assets: assets.map((asset) => mapAsset(asset)),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,
showExif: sharedLink.showExif, showMetadata: sharedLink.showExif,
}; };
} }
export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLinkResponseDto { export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || []; const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
@ -57,10 +58,10 @@ export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLin
type: sharedLink.type, type: sharedLink.type,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: assets.map(mapAssetWithoutExif), assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,
showExif: sharedLink.showExif, showMetadata: sharedLink.showExif,
}; };
} }

View file

@ -34,7 +34,7 @@ export class SharedLinkCreateDto {
@Optional() @Optional()
@IsBoolean() @IsBoolean()
showExif?: boolean = true; showMetadata?: boolean = true;
} }
export class SharedLinkEditDto { export class SharedLinkEditDto {
@ -51,5 +51,5 @@ export class SharedLinkEditDto {
allowDownload?: boolean; allowDownload?: boolean;
@Optional() @Optional()
showExif?: boolean; showMetadata?: boolean;
} }

View file

@ -59,10 +59,10 @@ describe(SharedLinkService.name, () => {
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId); expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
}); });
it('should return not return exif', async () => { it('should not return metadata', async () => {
const authDto = authStub.adminSharedLinkNoExif; const authDto = authStub.adminSharedLinkNoExif;
shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoExif); await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId); expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
}); });
}); });
@ -137,7 +137,7 @@ describe(SharedLinkService.name, () => {
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
assetIds: [assetStub.image.id], assetIds: [assetStub.image.id],
showExif: true, showMetadata: true,
allowDownload: true, allowDownload: true,
allowUpload: true, allowUpload: true,
}); });

View file

@ -4,7 +4,7 @@ import { AccessCore, Permission } from '../access';
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset'; import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories'; import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories';
import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithNoExif } from './shared-link-response.dto'; import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto';
import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto'; import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto';
@Injectable() @Injectable()
@ -24,7 +24,7 @@ export class SharedLinkService {
} }
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> { async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
const { sharedLinkId: id, isPublicUser, isShowExif } = authUser; const { sharedLinkId: id, isPublicUser, isShowMetadata: isShowExif } = authUser;
if (!isPublicUser || !id) { if (!isPublicUser || !id) {
throw new ForbiddenException(); throw new ForbiddenException();
@ -69,7 +69,7 @@ export class SharedLinkService {
expiresAt: dto.expiresAt || null, expiresAt: dto.expiresAt || null,
allowUpload: dto.allowUpload ?? true, allowUpload: dto.allowUpload ?? true,
allowDownload: dto.allowDownload ?? true, allowDownload: dto.allowDownload ?? true,
showExif: dto.showExif ?? true, showExif: dto.showMetadata ?? true,
}); });
return this.map(sharedLink, { withExif: true }); return this.map(sharedLink, { withExif: true });
@ -84,7 +84,7 @@ export class SharedLinkService {
expiresAt: dto.expiresAt, expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload, allowUpload: dto.allowUpload,
allowDownload: dto.allowDownload, allowDownload: dto.allowDownload,
showExif: dto.showExif, showExif: dto.showMetadata,
}); });
return this.map(sharedLink, { withExif: true }); return this.map(sharedLink, { withExif: true });
} }
@ -157,6 +157,6 @@ export class SharedLinkService {
} }
private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) { private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink); return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
} }
} }

View file

@ -47,7 +47,7 @@ export class TagService {
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> { async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
await this.findOrFail(authUser, id); await this.findOrFail(authUser, id);
const assets = await this.repository.getAssets(authUser.id, id); const assets = await this.repository.getAssets(authUser.id, id);
return assets.map(mapAsset); return assets.map((asset) => mapAsset(asset));
} }
async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> { async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {

View file

@ -186,7 +186,7 @@ export class AssetController {
@SharedLinkRoute() @SharedLinkRoute()
@Get('/assetById/:id') @Get('/assetById/:id')
getAssetById(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> { getAssetById(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.assetService.getAssetById(authUser, id); return this.assetService.getAssetById(authUser, id) as Promise<AssetResponseDto>;
} }
/** /**

View file

@ -10,9 +10,9 @@ import {
IStorageRepository, IStorageRepository,
JobName, JobName,
mapAsset, mapAsset,
mapAssetWithoutExif,
mimeTypes, mimeTypes,
Permission, Permission,
SanitizedAssetResponseDto,
UploadFile, UploadFile,
} from '@app/domain'; } from '@app/domain';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
@ -187,12 +187,16 @@ export class AssetService {
return assets.map((asset) => mapAsset(asset)); return assets.map((asset) => mapAsset(asset));
} }
public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> { public async getAssetById(
authUser: AuthUserDto,
assetId: string,
): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId); await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId);
const allowExif = this.getExifPermission(authUser); const includeMetadata = this.getExifPermission(authUser);
const asset = await this._assetRepository.getById(assetId); const asset = await this._assetRepository.getById(assetId);
const data = allowExif ? mapAsset(asset) : mapAssetWithoutExif(asset); if (includeMetadata) {
const data = mapAsset(asset);
if (data.ownerId !== authUser.id) { if (data.ownerId !== authUser.id) {
data.people = []; data.people = [];
@ -203,6 +207,9 @@ export class AssetService {
} }
return data; return data;
} else {
return mapAsset(asset, true);
}
} }
async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) { async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) {
@ -374,7 +381,7 @@ export class AssetService {
} }
getExifPermission(authUser: AuthUserDto) { getExifPermission(authUser: AuthUserDto) {
return !authUser.isPublicUser || authUser.isShowExif; return !authUser.isPublicUser || authUser.isShowMetadata;
} }
private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) { private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {

View file

@ -98,7 +98,7 @@ export class AssetController {
@Authenticated({ isShared: true }) @Authenticated({ isShared: true })
@Get('time-bucket') @Get('time-bucket')
getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> { getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getByTimeBucket(authUser, dto); return this.service.getByTimeBucket(authUser, dto) as Promise<AssetResponseDto[]>;
} }
@Post('jobs') @Post('jobs')

View file

@ -10,4 +10,11 @@ export const sharedLinkApi = {
expect(status).toBe(201); expect(status).toBe(201);
return body as SharedLinkResponseDto; return body as SharedLinkResponseDto;
}, },
getMySharedLink: async (server: any, key: string) => {
const { status, body } = await request(server).get('/shared-link/me').query({ key });
expect(status).toBe(200);
return body as SharedLinkResponseDto;
},
}; };

@ -1 +1 @@
Subproject commit 9e6e1bcc245e0ae0285bb596faf310ead851fac6 Subproject commit 948f353e3c9b66156c86c86cf078e0746ec1598e

View file

@ -1,11 +1,17 @@
import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain'; import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain';
import { PartnerController } from '@app/immich'; import { PartnerController } from '@app/immich';
import { SharedLinkType } from '@app/infra/entities'; import { LibraryType, SharedLinkType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { api } from '@test/api'; import { api } from '@test/api';
import { db } from '@test/db'; import { db } from '@test/db';
import { errorStub, uuidStub } from '@test/fixtures'; import { errorStub, uuidStub } from '@test/fixtures';
import { createTestApp } from '@test/test-utils'; import {
IMMICH_TEST_ASSET_PATH,
IMMICH_TEST_ASSET_TEMP_PATH,
createTestApp,
restoreTempFolder,
} from '@test/test-utils';
import { cp } from 'fs/promises';
import request from 'supertest'; import request from 'supertest';
const user1Dto = { const user1Dto = {
@ -18,24 +24,22 @@ const user1Dto = {
describe(`${PartnerController.name} (e2e)`, () => { describe(`${PartnerController.name} (e2e)`, () => {
let app: INestApplication; let app: INestApplication;
let server: any; let server: any;
let loginResponse: LoginResponseDto; let admin: LoginResponseDto;
let accessToken: string;
let user1: LoginResponseDto; let user1: LoginResponseDto;
let album: AlbumResponseDto; let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto; let sharedLink: SharedLinkResponseDto;
beforeAll(async () => { beforeAll(async () => {
app = await createTestApp(); app = await createTestApp(true);
server = app.getHttpServer(); server = app.getHttpServer();
}); });
beforeEach(async () => { beforeEach(async () => {
await db.reset(); await db.reset();
await api.authApi.adminSignUp(server); await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server); admin = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
await api.userApi.create(server, accessToken, user1Dto); await api.userApi.create(server, admin.accessToken, user1Dto);
user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
album = await api.albumApi.create(server, user1.accessToken, { albumName: 'shared with link' }); album = await api.albumApi.create(server, user1.accessToken, { albumName: 'shared with link' });
@ -48,6 +52,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
afterAll(async () => { afterAll(async () => {
await db.disconnect(); await db.disconnect();
await app.close(); await app.close();
await restoreTempFolder();
}); });
describe('GET /shared-link', () => { describe('GET /shared-link', () => {
@ -68,7 +73,9 @@ describe(`${PartnerController.name} (e2e)`, () => {
}); });
it('should not get shared links created by other users', async () => { it('should not get shared links created by other users', async () => {
const { status, body } = await request(server).get('/shared-link').set('Authorization', `Bearer ${accessToken}`); const { status, body } = await request(server)
.get('/shared-link')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([]); expect(body).toEqual([]);
@ -77,7 +84,9 @@ describe(`${PartnerController.name} (e2e)`, () => {
describe('GET /shared-link/me', () => { describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => { it('should not require admin authentication', async () => {
const { status } = await request(server).get('/shared-link/me').set('Authorization', `Bearer ${accessToken}`); const { status } = await request(server)
.get('/shared-link/me')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(403); expect(status).toBe(403);
}); });
@ -104,7 +113,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
type: SharedLinkType.ALBUM, type: SharedLinkType.ALBUM,
albumId: softDeletedAlbum.id, albumId: softDeletedAlbum.id,
}); });
await api.userApi.delete(server, accessToken, user1.userId); await api.userApi.delete(server, admin.accessToken, user1.userId);
const { status, body } = await request(server).get('/shared-link/me').query({ key: softDeletedAlbumLink.key }); const { status, body } = await request(server).get('/shared-link/me').query({ key: softDeletedAlbumLink.key });
@ -133,7 +142,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
it('should not get shared link by id if user has not created the link or it does not exist', async () => { it('should not get shared link by id if user has not created the link or it does not exist', async () => {
const { status, body } = await request(server) const { status, body } = await request(server)
.get(`/shared-link/${sharedLink.id}`) .get(`/shared-link/${sharedLink.id}`)
.set('Authorization', `Bearer ${accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' })); expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
@ -248,4 +257,81 @@ describe(`${PartnerController.name} (e2e)`, () => {
expect(status).toBe(200); expect(status).toBe(200);
}); });
}); });
describe('Shared link metadata', () => {
beforeEach(async () => {
await restoreTempFolder();
await cp(
`${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`,
`${IMMICH_TEST_ASSET_TEMP_PATH}/thompson-springs.jpg`,
);
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toHaveLength(1);
album = await api.albumApi.create(server, admin.accessToken, { albumName: 'New album' });
await api.albumApi.addAssets(server, admin.accessToken, album.id, { ids: [assets[0].id] });
});
it('should return metadata for album shared link', async () => {
const sharedLink = await api.sharedLinkApi.create(server, admin.accessToken, {
type: SharedLinkType.ALBUM,
albumId: album.id,
});
const returnedLink = await api.sharedLinkApi.getMySharedLink(server, sharedLink.key);
expect(returnedLink.assets).toHaveLength(1);
expect(returnedLink.album).toBeDefined();
const returnedAsset = returnedLink.assets[0];
expect(returnedAsset).toEqual(
expect.objectContaining({
originalFileName: 'thompson-springs',
resized: true,
localDateTime: '2022-01-10T15:15:44.310Z',
fileCreatedAt: '2022-01-10T19:15:44.310Z',
exifInfo: expect.objectContaining({
longitude: -108.400968333333,
latitude: 39.115,
orientation: '1',
dateTimeOriginal: '2022-01-10T19:15:44.310Z',
timeZone: 'UTC-4',
state: 'Mesa County, Colorado',
country: 'United States of America',
}),
}),
);
});
it('should not return metadata for album shared link without metadata', async () => {
const sharedLink = await api.sharedLinkApi.create(server, admin.accessToken, {
type: SharedLinkType.ALBUM,
albumId: album.id,
showMetadata: false,
});
const returnedLink = await api.sharedLinkApi.getMySharedLink(server, sharedLink.key);
expect(returnedLink.assets).toHaveLength(1);
expect(returnedLink.album).toBeDefined();
const returnedAsset = returnedLink.assets[0];
expect(returnedAsset).not.toHaveProperty('exifInfo');
expect(returnedAsset).not.toHaveProperty('fileCreatedAt');
expect(returnedAsset).not.toHaveProperty('originalFilename');
expect(returnedAsset).not.toHaveProperty('originalPath');
});
});
}); });

View file

@ -48,7 +48,7 @@ export const authStub = {
isPublicUser: false, isPublicUser: false,
isAllowUpload: true, isAllowUpload: true,
isAllowDownload: true, isAllowDownload: true,
isShowExif: true, isShowMetadata: true,
accessTokenId: 'token-id', accessTokenId: 'token-id',
externalPath: null, externalPath: null,
}), }),
@ -59,7 +59,7 @@ export const authStub = {
isPublicUser: false, isPublicUser: false,
isAllowUpload: true, isAllowUpload: true,
isAllowDownload: true, isAllowDownload: true,
isShowExif: true, isShowMetadata: true,
accessTokenId: 'token-id', accessTokenId: 'token-id',
externalPath: null, externalPath: null,
}), }),
@ -70,7 +70,7 @@ export const authStub = {
isPublicUser: false, isPublicUser: false,
isAllowUpload: true, isAllowUpload: true,
isAllowDownload: true, isAllowDownload: true,
isShowExif: true, isShowMetadata: true,
accessTokenId: 'token-id', accessTokenId: 'token-id',
externalPath: '/data/user1', externalPath: '/data/user1',
}), }),
@ -81,7 +81,7 @@ export const authStub = {
isAllowUpload: true, isAllowUpload: true,
isAllowDownload: true, isAllowDownload: true,
isPublicUser: true, isPublicUser: true,
isShowExif: true, isShowMetadata: true,
sharedLinkId: '123', sharedLinkId: '123',
}), }),
adminSharedLinkNoExif: Object.freeze<AuthUserDto>({ adminSharedLinkNoExif: Object.freeze<AuthUserDto>({
@ -91,7 +91,7 @@ export const authStub = {
isAllowUpload: true, isAllowUpload: true,
isAllowDownload: true, isAllowDownload: true,
isPublicUser: true, isPublicUser: true,
isShowExif: false, isShowMetadata: false,
sharedLinkId: '123', sharedLinkId: '123',
}), }),
readonlySharedLink: Object.freeze<AuthUserDto>({ readonlySharedLink: Object.freeze<AuthUserDto>({
@ -101,7 +101,7 @@ export const authStub = {
isAllowUpload: false, isAllowUpload: false,
isAllowDownload: false, isAllowDownload: false,
isPublicUser: true, isPublicUser: true,
isShowExif: true, isShowMetadata: true,
sharedLinkId: '123', sharedLinkId: '123',
accessTokenId: 'token-id', accessTokenId: 'token-id',
}), }),

View file

@ -71,8 +71,20 @@ const assetResponse: AssetResponseDto = {
checksum: 'ZmlsZSBoYXNo', checksum: 'ZmlsZSBoYXNo',
isTrashed: false, isTrashed: false,
libraryId: 'library-id', libraryId: 'library-id',
hasMetadata: true,
}; };
const assetResponseWithoutMetadata = {
id: 'id_1',
type: AssetType.VIDEO,
resized: false,
thumbhash: null,
localDateTime: today,
duration: '0:00:00.00000',
livePhotoVideoId: null,
hasMetadata: false,
} as AssetResponseDto;
const albumResponse: AlbumResponseDto = { const albumResponse: AlbumResponseDto = {
albumName: 'Test Album', albumName: 'Test Album',
description: '', description: '',
@ -253,7 +265,7 @@ export const sharedLinkResponseStub = {
expiresAt: tomorrow, expiresAt: tomorrow,
id: '123', id: '123',
key: sharedLinkBytes.toString('base64url'), key: sharedLinkBytes.toString('base64url'),
showExif: true, showMetadata: true,
type: SharedLinkType.ALBUM, type: SharedLinkType.ALBUM,
userId: 'admin_id', userId: 'admin_id',
}), }),
@ -267,7 +279,7 @@ export const sharedLinkResponseStub = {
expiresAt: yesterday, expiresAt: yesterday,
id: '123', id: '123',
key: sharedLinkBytes.toString('base64url'), key: sharedLinkBytes.toString('base64url'),
showExif: true, showMetadata: true,
type: SharedLinkType.ALBUM, type: SharedLinkType.ALBUM,
userId: 'admin_id', userId: 'admin_id',
}), }),
@ -281,11 +293,11 @@ export const sharedLinkResponseStub = {
description: null, description: null,
allowUpload: false, allowUpload: false,
allowDownload: false, allowDownload: false,
showExif: true, showMetadata: true,
album: albumResponse, album: albumResponse,
assets: [assetResponse], assets: [assetResponse],
}), }),
readonlyNoExif: Object.freeze<SharedLinkResponseDto>({ readonlyNoMetadata: Object.freeze<SharedLinkResponseDto>({
id: '123', id: '123',
userId: 'admin_id', userId: 'admin_id',
key: sharedLinkBytes.toString('base64url'), key: sharedLinkBytes.toString('base64url'),
@ -295,8 +307,8 @@ export const sharedLinkResponseStub = {
description: null, description: null,
allowUpload: false, allowUpload: false,
allowDownload: false, allowDownload: false,
showExif: false, showMetadata: false,
album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt }, album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt },
assets: [{ ...assetResponse, exifInfo: undefined }], assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }],
}), }),
}; };

View file

@ -640,6 +640,12 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
'fileModifiedAt': string; 'fileModifiedAt': string;
/**
*
* @type {boolean}
* @memberof AssetResponseDto
*/
'hasMetadata': boolean;
/** /**
* *
* @type {string} * @type {string}
@ -749,7 +755,7 @@ export interface AssetResponseDto {
*/ */
'tags'?: Array<TagResponseDto>; 'tags'?: Array<TagResponseDto>;
/** /**
* base64 encoded thumbhash *
* @type {string} * @type {string}
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
@ -2882,7 +2888,7 @@ export interface SharedLinkCreateDto {
* @type {boolean} * @type {boolean}
* @memberof SharedLinkCreateDto * @memberof SharedLinkCreateDto
*/ */
'showExif'?: boolean; 'showMetadata'?: boolean;
/** /**
* *
* @type {SharedLinkType} * @type {SharedLinkType}
@ -2927,7 +2933,7 @@ export interface SharedLinkEditDto {
* @type {boolean} * @type {boolean}
* @memberof SharedLinkEditDto * @memberof SharedLinkEditDto
*/ */
'showExif'?: boolean; 'showMetadata'?: boolean;
} }
/** /**
* *
@ -2994,7 +3000,7 @@ export interface SharedLinkResponseDto {
* @type {boolean} * @type {boolean}
* @memberof SharedLinkResponseDto * @memberof SharedLinkResponseDto
*/ */
'showExif': boolean; 'showMetadata': boolean;
/** /**
* *
* @type {SharedLinkType} * @type {SharedLinkType}

View file

@ -28,6 +28,7 @@
export let showMotionPlayButton: boolean; export let showMotionPlayButton: boolean;
export let isMotionPhotoPlaying = false; export let isMotionPhotoPlaying = false;
export let showDownloadButton: boolean; export let showDownloadButton: boolean;
export let showDetailButton: boolean;
export let showSlideshow = false; export let showSlideshow = false;
const isOwner = asset.ownerId === $page.data.user?.id; const isOwner = asset.ownerId === $page.data.user?.id;
@ -133,7 +134,14 @@
title="Download" title="Download"
/> />
{/if} {/if}
<CircleIconButton isOpacity={true} logo={InformationOutline} on:click={() => dispatch('showDetail')} title="Info" /> {#if showDetailButton}
<CircleIconButton
isOpacity={true}
logo={InformationOutline}
on:click={() => dispatch('showDetail')}
title="Info"
/>
{/if}
{#if isOwner} {#if isOwner}
<CircleIconButton <CircleIconButton
isOpacity={true} isOpacity={true}

View file

@ -55,6 +55,7 @@
let shouldPlayMotionPhoto = false; let shouldPlayMotionPhoto = false;
let isShowProfileImageCrop = false; let isShowProfileImageCrop = false;
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
let shouldShowDetailButton = asset.hasMetadata;
let canCopyImagesToClipboard: boolean; let canCopyImagesToClipboard: boolean;
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo); const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo);
@ -392,6 +393,7 @@
showZoomButton={asset.type === AssetTypeEnum.Image} showZoomButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId} showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton} showDownloadButton={shouldShowDownloadButton}
showDetailButton={shouldShowDetailButton}
showSlideshow={!!assetStore} showSlideshow={!!assetStore}
on:goBack={closeViewer} on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler} on:showDetail={showDetailInfoHandler}
@ -433,9 +435,9 @@
on:close={closeViewer} on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/> />
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || asset.originalPath {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase() .toLowerCase()
.endsWith('.insp')} .endsWith('.insp'))}
<PanoramaViewer {asset} /> <PanoramaViewer {asset} />
{:else} {:else}
<PhotoViewer {asset} on:close={closeViewer} /> <PhotoViewer {asset} on:close={closeViewer} />

View file

@ -21,7 +21,7 @@
let description = ''; let description = '';
let allowDownload = true; let allowDownload = true;
let allowUpload = false; let allowUpload = false;
let showExif = true; let showMetadata = true;
let expirationTime = ''; let expirationTime = '';
let shouldChangeExpirationTime = false; let shouldChangeExpirationTime = false;
let canCopyImagesToClipboard = true; let canCopyImagesToClipboard = true;
@ -41,7 +41,7 @@
} }
allowUpload = editingLink.allowUpload; allowUpload = editingLink.allowUpload;
allowDownload = editingLink.allowDownload; allowDownload = editingLink.allowDownload;
showExif = editingLink.showExif; showMetadata = editingLink.showMetadata;
albumId = editingLink.album?.id; albumId = editingLink.album?.id;
assetIds = editingLink.assets.map(({ id }) => id); assetIds = editingLink.assets.map(({ id }) => id);
@ -66,7 +66,7 @@
allowUpload, allowUpload,
description, description,
allowDownload, allowDownload,
showExif, showMetadata,
}, },
}); });
sharedLink = `${window.location.origin}/share/${data.key}`; sharedLink = `${window.location.origin}/share/${data.key}`;
@ -119,9 +119,9 @@
sharedLinkEditDto: { sharedLinkEditDto: {
description, description,
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined, expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload: allowUpload, allowUpload,
allowDownload: allowDownload, allowDownload,
showExif: showExif, showMetadata,
}, },
}); });
@ -184,7 +184,7 @@
</div> </div>
<div class="my-3"> <div class="my-3">
<SettingSwitch bind:checked={showExif} title={'Show metadata'} /> <SettingSwitch bind:checked={showMetadata} title={'Show metadata'} />
</div> </div>
<div class="my-3"> <div class="my-3">

View file

@ -136,7 +136,7 @@
</div> </div>
{/if} {/if}
{#if link.showExif} {#if link.showMetadata}
<div <div
class="flex w-[60px] place-content-center place-items-center rounded-full bg-immich-primary px-2 py-1 text-xs text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray" class="flex w-[60px] place-content-center place-items-center rounded-full bg-immich-primary px-2 py-1 text-xs text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray"
> >