mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 04:09:07 +00: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:
committed by
GitHub
parent
4a9f58bf9b
commit
dadcf49eca
@@ -47,6 +47,7 @@ import {
|
||||
BulkIdsDto,
|
||||
MapMarkerResponseDto,
|
||||
MemoryLaneResponseDto,
|
||||
SanitizedAssetResponseDto,
|
||||
TimeBucketResponseDto,
|
||||
mapAsset,
|
||||
} from './response-dto';
|
||||
@@ -198,10 +199,17 @@ export class AssetService {
|
||||
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);
|
||||
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> {
|
||||
|
||||
@@ -6,43 +6,62 @@ import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.
|
||||
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
|
||||
|
||||
export class AssetResponseDto {
|
||||
export class SanitizedAssetResponseDto {
|
||||
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;
|
||||
deviceId!: string;
|
||||
ownerId!: string;
|
||||
owner?: UserResponseDto;
|
||||
libraryId!: string;
|
||||
|
||||
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
||||
type!: AssetType;
|
||||
originalPath!: string;
|
||||
originalFileName!: string;
|
||||
resized!: boolean;
|
||||
/**base64 encoded thumbhash */
|
||||
thumbhash!: string | null;
|
||||
fileCreatedAt!: Date;
|
||||
fileModifiedAt!: Date;
|
||||
updatedAt!: Date;
|
||||
isFavorite!: boolean;
|
||||
isArchived!: boolean;
|
||||
isTrashed!: boolean;
|
||||
localDateTime!: Date;
|
||||
isOffline!: boolean;
|
||||
isExternal!: boolean;
|
||||
isReadOnly!: boolean;
|
||||
duration!: string;
|
||||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
livePhotoVideoId?: string | null;
|
||||
tags?: TagResponseDto[];
|
||||
people?: PersonResponseDto[];
|
||||
/**base64 encoded sha1 hash */
|
||||
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 {
|
||||
...sanitizedAssetResponse,
|
||||
id: entity.id,
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.ownerId,
|
||||
@@ -62,7 +81,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
|
||||
isArchived: entity.isArchived,
|
||||
isTrashed: !!entity.deletedAt,
|
||||
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,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map(mapTag),
|
||||
@@ -71,17 +90,10 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
|
||||
isExternal: entity.isExternal,
|
||||
isOffline: entity.isOffline,
|
||||
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 {
|
||||
title!: string;
|
||||
assets!: AssetResponseDto[];
|
||||
|
||||
@@ -52,3 +52,15 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ export class AuthService {
|
||||
sharedLinkId: link.id,
|
||||
isAllowUpload: link.allowUpload,
|
||||
isAllowDownload: link.allowDownload,
|
||||
isShowExif: link.showExif,
|
||||
isShowMetadata: link.showExif,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -431,7 +431,7 @@ export class AuthService {
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
isAllowDownload: true,
|
||||
isShowExif: true,
|
||||
isShowMetadata: true,
|
||||
accessTokenId: token.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export class AuthUserDto {
|
||||
sharedLinkId?: string;
|
||||
isAllowUpload?: boolean;
|
||||
isAllowDownload?: boolean;
|
||||
isShowExif?: boolean;
|
||||
isShowMetadata?: boolean;
|
||||
accessTokenId?: string;
|
||||
externalPath?: string | null;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export class PersonService {
|
||||
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
|
||||
await this.access.requirePermission(authUser, Permission.PERSON_READ, 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> {
|
||||
|
||||
@@ -154,7 +154,7 @@ export class SearchService {
|
||||
items: assets.items
|
||||
.map((item) => lookup[item.id])
|
||||
.filter((item) => !!item)
|
||||
.map(mapAsset),
|
||||
.map((asset) => mapAsset(asset)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { AlbumResponseDto, mapAlbumWithoutAssets } from '../album';
|
||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset';
|
||||
import { AssetResponseDto, mapAsset } from '../asset';
|
||||
|
||||
export class SharedLinkResponseDto {
|
||||
id!: string;
|
||||
@@ -17,8 +17,9 @@ export class SharedLinkResponseDto {
|
||||
assets!: AssetResponseDto[];
|
||||
album?: AlbumResponseDto;
|
||||
allowUpload!: boolean;
|
||||
|
||||
allowDownload!: boolean;
|
||||
showExif!: boolean;
|
||||
showMetadata!: boolean;
|
||||
}
|
||||
|
||||
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
|
||||
@@ -35,15 +36,15 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
assets: assets.map(mapAsset),
|
||||
assets: assets.map((asset) => mapAsset(asset)),
|
||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
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 albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
|
||||
|
||||
@@ -57,10 +58,10 @@ export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLin
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
assets: assets.map(mapAssetWithoutExif),
|
||||
assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
|
||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showExif: sharedLink.showExif,
|
||||
showMetadata: sharedLink.showExif,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class SharedLinkCreateDto {
|
||||
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
showExif?: boolean = true;
|
||||
showMetadata?: boolean = true;
|
||||
}
|
||||
|
||||
export class SharedLinkEditDto {
|
||||
@@ -51,5 +51,5 @@ export class SharedLinkEditDto {
|
||||
allowDownload?: boolean;
|
||||
|
||||
@Optional()
|
||||
showExif?: boolean;
|
||||
showMetadata?: boolean;
|
||||
}
|
||||
|
||||
@@ -59,10 +59,10 @@ describe(SharedLinkService.name, () => {
|
||||
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;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -137,7 +137,7 @@ describe(SharedLinkService.name, () => {
|
||||
await sut.create(authStub.admin, {
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
assetIds: [assetStub.image.id],
|
||||
showExif: true,
|
||||
showMetadata: true,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AccessCore, Permission } from '../access';
|
||||
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
@@ -24,7 +24,7 @@ export class SharedLinkService {
|
||||
}
|
||||
|
||||
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
|
||||
const { sharedLinkId: id, isPublicUser, isShowExif } = authUser;
|
||||
const { sharedLinkId: id, isPublicUser, isShowMetadata: isShowExif } = authUser;
|
||||
|
||||
if (!isPublicUser || !id) {
|
||||
throw new ForbiddenException();
|
||||
@@ -69,7 +69,7 @@ export class SharedLinkService {
|
||||
expiresAt: dto.expiresAt || null,
|
||||
allowUpload: dto.allowUpload ?? true,
|
||||
allowDownload: dto.allowDownload ?? true,
|
||||
showExif: dto.showExif ?? true,
|
||||
showExif: dto.showMetadata ?? true,
|
||||
});
|
||||
|
||||
return this.map(sharedLink, { withExif: true });
|
||||
@@ -84,7 +84,7 @@ export class SharedLinkService {
|
||||
expiresAt: dto.expiresAt,
|
||||
allowUpload: dto.allowUpload,
|
||||
allowDownload: dto.allowDownload,
|
||||
showExif: dto.showExif,
|
||||
showExif: dto.showMetadata,
|
||||
});
|
||||
return this.map(sharedLink, { withExif: true });
|
||||
}
|
||||
@@ -157,6 +157,6 @@ export class SharedLinkService {
|
||||
}
|
||||
|
||||
private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
|
||||
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink);
|
||||
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export class TagService {
|
||||
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
|
||||
await this.findOrFail(authUser, 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[]> {
|
||||
|
||||
@@ -186,7 +186,7 @@ export class AssetController {
|
||||
@SharedLinkRoute()
|
||||
@Get('/assetById/:id')
|
||||
getAssetById(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
|
||||
return this.assetService.getAssetById(authUser, id);
|
||||
return this.assetService.getAssetById(authUser, id) as Promise<AssetResponseDto>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
IStorageRepository,
|
||||
JobName,
|
||||
mapAsset,
|
||||
mapAssetWithoutExif,
|
||||
mimeTypes,
|
||||
Permission,
|
||||
SanitizedAssetResponseDto,
|
||||
UploadFile,
|
||||
} from '@app/domain';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
|
||||
@@ -187,22 +187,29 @@ export class AssetService {
|
||||
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);
|
||||
|
||||
const allowExif = this.getExifPermission(authUser);
|
||||
const includeMetadata = this.getExifPermission(authUser);
|
||||
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) {
|
||||
data.people = [];
|
||||
if (data.ownerId !== authUser.id) {
|
||||
data.people = [];
|
||||
}
|
||||
|
||||
if (authUser.isPublicUser) {
|
||||
delete data.owner;
|
||||
}
|
||||
|
||||
return data;
|
||||
} else {
|
||||
return mapAsset(asset, true);
|
||||
}
|
||||
|
||||
if (authUser.isPublicUser) {
|
||||
delete data.owner;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) {
|
||||
@@ -374,7 +381,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
getExifPermission(authUser: AuthUserDto) {
|
||||
return !authUser.isPublicUser || authUser.isShowExif;
|
||||
return !authUser.isPublicUser || authUser.isShowMetadata;
|
||||
}
|
||||
|
||||
private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {
|
||||
|
||||
@@ -98,7 +98,7 @@ export class AssetController {
|
||||
@Authenticated({ isShared: true })
|
||||
@Get('time-bucket')
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user