mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web/server) Add more options to public shared link (#1348)
* Added migration files * Added logic for shared album level * Added permission for EXIF * Update shared link response dto * Added condition to show download button * Create and edit link with new parameter: * Remove deadcode * PR feedback * More refactor * Move logic of allow original file to service * Simplify * Wording
This commit is contained in:
parent
4cfac47674
commit
b07891089f
41 changed files with 520 additions and 73 deletions
|
|
@ -140,6 +140,8 @@ export class AlbumController {
|
|||
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
): Promise<any> {
|
||||
this.albumService.checkDownloadAccess(authUser);
|
||||
|
||||
const { stream, fileName, fileSize, fileCount, complete } = await this.albumService.downloadArchive(
|
||||
authUser,
|
||||
albumId,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { DownloadService } from '../../modules/download/download.service';
|
|||
import { DownloadDto } from '../asset/dto/download-library.dto';
|
||||
import { ShareCore } from '../share/share.core';
|
||||
import { ISharedLinkRepository } from '../share/shared-link.repository';
|
||||
import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
|
||||
import { mapSharedLink, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
|
||||
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
|
||||
import _ from 'lodash';
|
||||
|
||||
|
|
@ -210,8 +210,14 @@ export class AlbumService {
|
|||
album: album,
|
||||
assets: [],
|
||||
description: dto.description,
|
||||
allowDownload: dto.allowDownload,
|
||||
showExif: dto.showExif,
|
||||
});
|
||||
|
||||
return mapSharedLinkToResponseDto(sharedLink);
|
||||
return mapSharedLink(sharedLink);
|
||||
}
|
||||
|
||||
checkDownloadAccess(authUser: AuthUserDto) {
|
||||
this.shareCore.checkDownloadAccess(authUser);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ export class CreateAlbumShareLinkDto {
|
|||
@IsOptional()
|
||||
allowUpload?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
allowDownload?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
showExif?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export class AssetController {
|
|||
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
|
||||
@Param('assetId') assetId: string,
|
||||
): Promise<any> {
|
||||
this.assetService.checkDownloadAccess(authUser);
|
||||
await this.assetService.checkAssetsAccess(authUser, [assetId]);
|
||||
return this.assetService.downloadFile(query, assetId, res);
|
||||
}
|
||||
|
|
@ -108,6 +109,7 @@ export class AssetController {
|
|||
@Response({ passthrough: true }) res: Res,
|
||||
@Body(new ValidationPipe()) dto: DownloadFilesDto,
|
||||
): Promise<any> {
|
||||
this.assetService.checkDownloadAccess(authUser);
|
||||
await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]);
|
||||
const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto);
|
||||
res.attachment(fileName);
|
||||
|
|
@ -117,6 +119,9 @@ export class AssetController {
|
|||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current this is not used in any UI element
|
||||
*/
|
||||
@Authenticated({ isShared: true })
|
||||
@Get('/download-library')
|
||||
async downloadLibrary(
|
||||
|
|
@ -124,6 +129,7 @@ export class AssetController {
|
|||
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
): Promise<any> {
|
||||
this.assetService.checkDownloadAccess(authUser);
|
||||
const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadLibrary(authUser, dto);
|
||||
res.attachment(fileName);
|
||||
res.setHeader(IMMICH_CONTENT_LENGTH_HINT, fileSize);
|
||||
|
|
@ -143,7 +149,7 @@ export class AssetController {
|
|||
@Param('assetId') assetId: string,
|
||||
): Promise<any> {
|
||||
await this.assetService.checkAssetsAccess(authUser, [assetId]);
|
||||
return this.assetService.serveFile(assetId, query, res, headers);
|
||||
return this.assetService.serveFile(authUser, assetId, query, res, headers);
|
||||
}
|
||||
|
||||
@Authenticated({ isShared: true })
|
||||
|
|
@ -246,7 +252,7 @@ export class AssetController {
|
|||
@Param('assetId') assetId: string,
|
||||
): Promise<AssetResponseDto> {
|
||||
await this.assetService.checkAssetsAccess(authUser, [assetId]);
|
||||
return await this.assetService.getAssetById(assetId);
|
||||
return await this.assetService.getAssetById(authUser, assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -274,14 +280,14 @@ export class AssetController {
|
|||
const deleteAssetList: AssetResponseDto[] = [];
|
||||
|
||||
for (const id of assetIds.ids) {
|
||||
const assets = await this.assetService.getAssetById(id);
|
||||
const assets = await this.assetService.getAssetById(authUser, id);
|
||||
if (!assets) {
|
||||
continue;
|
||||
}
|
||||
deleteAssetList.push(assets);
|
||||
|
||||
if (assets.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetService.getAssetById(assets.livePhotoVideoId);
|
||||
const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
|
||||
if (livePhotoVideo) {
|
||||
deleteAssetList.push(livePhotoVideo);
|
||||
assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { SearchAssetDto } from './dto/search-asset.dto';
|
|||
import fs from 'fs/promises';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
import { AssetResponseDto, mapAsset } from './response-dto/asset-response.dto';
|
||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from './response-dto/asset-response.dto';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
||||
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
|
||||
|
|
@ -52,7 +52,7 @@ import { ShareCore } from '../share/share.core';
|
|||
import { ISharedLinkRepository } from '../share/shared-link.repository';
|
||||
import { DownloadFilesDto } from './dto/download-files.dto';
|
||||
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
|
||||
import { mapSharedLink, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
|
||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
|
@ -215,10 +215,15 @@ export class AssetService {
|
|||
return assets.map((asset) => mapAsset(asset));
|
||||
}
|
||||
|
||||
public async getAssetById(assetId: string): Promise<AssetResponseDto> {
|
||||
public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
|
||||
const allowExif = this.getExifPermission(authUser);
|
||||
const asset = await this._assetRepository.getById(assetId);
|
||||
|
||||
return mapAsset(asset);
|
||||
if (allowExif) {
|
||||
return mapAsset(asset);
|
||||
} else {
|
||||
return mapAssetWithoutExif(asset);
|
||||
}
|
||||
}
|
||||
|
||||
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
||||
|
|
@ -356,7 +361,15 @@ export class AssetService {
|
|||
}
|
||||
}
|
||||
|
||||
public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: Record<string, string>) {
|
||||
public async serveFile(
|
||||
authUser: AuthUserDto,
|
||||
assetId: string,
|
||||
query: ServeFileDto,
|
||||
res: Res,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
const allowOriginalFile = !authUser.isPublicUser || authUser.isAllowDownload;
|
||||
|
||||
let fileReadStream: ReadStream;
|
||||
const asset = await this._assetRepository.getById(assetId);
|
||||
|
||||
|
|
@ -390,7 +403,7 @@ export class AssetService {
|
|||
/**
|
||||
* Serve thumbnail image for both web and mobile app
|
||||
*/
|
||||
if (!query.isThumb) {
|
||||
if (!query.isThumb && allowOriginalFile) {
|
||||
res.set({
|
||||
'Content-Type': asset.mimeType,
|
||||
});
|
||||
|
|
@ -676,6 +689,10 @@ export class AssetService {
|
|||
}
|
||||
}
|
||||
|
||||
checkDownloadAccess(authUser: AuthUserDto) {
|
||||
this.shareCore.checkDownloadAccess(authUser);
|
||||
}
|
||||
|
||||
async createAssetsSharedLink(authUser: AuthUserDto, dto: CreateAssetsShareLinkDto): Promise<SharedLinkResponseDto> {
|
||||
const assets = [];
|
||||
|
||||
|
|
@ -691,9 +708,11 @@ export class AssetService {
|
|||
allowUpload: dto.allowUpload,
|
||||
assets: assets,
|
||||
description: dto.description,
|
||||
allowDownload: dto.allowDownload,
|
||||
showExif: dto.showExif,
|
||||
});
|
||||
|
||||
return mapSharedLinkToResponseDto(sharedLink);
|
||||
return mapSharedLink(sharedLink);
|
||||
}
|
||||
|
||||
async updateAssetsInSharedLink(
|
||||
|
|
@ -709,7 +728,11 @@ export class AssetService {
|
|||
}
|
||||
|
||||
const updatedLink = await this.shareCore.updateAssetsInSharedLink(authUser.sharedLinkId, assets);
|
||||
return mapSharedLinkToResponseDto(updatedLink);
|
||||
return mapSharedLink(updatedLink);
|
||||
}
|
||||
|
||||
getExifPermission(authUser: AuthUserDto) {
|
||||
return !authUser.isPublicUser || authUser.isShowExif;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ export class CreateAssetsShareLinkDto {
|
|||
@IsOptional()
|
||||
allowUpload?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
allowDownload?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
showExif?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
|
|
|||
|
|
@ -49,3 +49,26 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
|||
tags: entity.tags?.map(mapTag),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.userId,
|
||||
deviceId: entity.deviceId,
|
||||
type: entity.type,
|
||||
originalPath: entity.originalPath,
|
||||
resizePath: entity.resizePath,
|
||||
createdAt: entity.createdAt,
|
||||
modifiedAt: entity.modifiedAt,
|
||||
isFavorite: entity.isFavorite,
|
||||
mimeType: entity.mimeType,
|
||||
webpPath: entity.webpPath,
|
||||
encodedVideoPath: entity.encodedVideoPath,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
exifInfo: undefined,
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map(mapTag),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,4 +8,6 @@ export class CreateSharedLinkDto {
|
|||
assets!: AssetEntity[];
|
||||
album?: AlbumEntity;
|
||||
allowUpload?: boolean;
|
||||
allowDownload?: boolean;
|
||||
showExif?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ export class EditSharedLinkDto {
|
|||
@IsOptional()
|
||||
allowUpload?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
allowDownload?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
showExif?: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
isEditExpireTime?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra';
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto';
|
||||
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
|
||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset/response-dto/asset-response.dto';
|
||||
|
||||
export class SharedLinkResponseDto {
|
||||
id!: string;
|
||||
|
|
@ -17,9 +17,11 @@ export class SharedLinkResponseDto {
|
|||
assets!: AssetResponseDto[];
|
||||
album?: AlbumResponseDto;
|
||||
allowUpload!: boolean;
|
||||
allowDownload!: boolean;
|
||||
showExif!: boolean;
|
||||
}
|
||||
|
||||
export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
|
||||
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
|
||||
const linkAssets = sharedLink.assets || [];
|
||||
const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo);
|
||||
|
||||
|
|
@ -36,5 +38,29 @@ export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): Shared
|
|||
assets: assets.map(mapAsset),
|
||||
album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showExif: sharedLink.showExif,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
|
||||
const linkAssets = sharedLink.assets || [];
|
||||
const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo);
|
||||
|
||||
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
|
||||
|
||||
return {
|
||||
id: sharedLink.id,
|
||||
description: sharedLink.description,
|
||||
userId: sharedLink.userId,
|
||||
key: sharedLink.key.toString('hex'),
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
assets: assets.map(mapAssetWithoutExif),
|
||||
album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showExif: sharedLink.showExif,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export class ShareController {
|
|||
@Authenticated()
|
||||
@Get(':id')
|
||||
getSharedLinkById(@Param('id') id: string): Promise<SharedLinkResponseDto> {
|
||||
return this.shareService.getById(id);
|
||||
return this.shareService.getById(id, true);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { SharedLinkEntity } from '@app/infra';
|
|||
import { CreateSharedLinkDto } from './dto/create-shared-link.dto';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
import crypto from 'node:crypto';
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { AssetEntity } from '@app/infra';
|
||||
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
|
||||
export class ShareCore {
|
||||
readonly logger = new Logger(ShareCore.name);
|
||||
|
|
@ -24,6 +25,8 @@ export class ShareCore {
|
|||
sharedLink.assets = dto.assets;
|
||||
sharedLink.album = dto.album;
|
||||
sharedLink.allowUpload = dto.allowUpload ?? false;
|
||||
sharedLink.allowDownload = dto.allowDownload ?? true;
|
||||
sharedLink.showExif = dto.showExif ?? true;
|
||||
|
||||
return this.sharedLinkRepository.create(sharedLink);
|
||||
} catch (error: any) {
|
||||
|
|
@ -74,6 +77,8 @@ export class ShareCore {
|
|||
|
||||
link.description = dto.description ?? link.description;
|
||||
link.allowUpload = dto.allowUpload ?? link.allowUpload;
|
||||
link.allowDownload = dto.allowDownload ?? link.allowDownload;
|
||||
link.showExif = dto.showExif ?? link.showExif;
|
||||
|
||||
if (dto.isEditExpireTime && dto.expiredAt) {
|
||||
link.expiresAt = dto.expiredAt;
|
||||
|
|
@ -87,4 +92,10 @@ export class ShareCore {
|
|||
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
|
||||
return this.sharedLinkRepository.hasAssetAccess(id, assetId);
|
||||
}
|
||||
|
||||
checkDownloadAccess(user: AuthUserDto) {
|
||||
if (user.isPublicUser && !user.isAllowDownload) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { UserService } from '@app/domain';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
|
||||
import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
|
||||
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
|
||||
import { ShareCore } from './share.core';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
|
||||
|
|
@ -39,6 +39,8 @@ export class ShareService {
|
|||
isPublicUser: true,
|
||||
sharedLinkId: link.id,
|
||||
isAllowUpload: link.allowUpload,
|
||||
isAllowDownload: link.allowDownload,
|
||||
isShowExif: link.showExif,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -48,7 +50,7 @@ export class ShareService {
|
|||
|
||||
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
|
||||
const links = await this.shareCore.getSharedLinks(authUser.id);
|
||||
return links.map(mapSharedLinkToResponseDto);
|
||||
return links.map(mapSharedLink);
|
||||
}
|
||||
|
||||
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
|
||||
|
|
@ -56,15 +58,25 @@ export class ShareService {
|
|||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.getById(authUser.sharedLinkId);
|
||||
let allowExif = true;
|
||||
if (authUser.isShowExif != undefined) {
|
||||
allowExif = authUser.isShowExif;
|
||||
}
|
||||
|
||||
return this.getById(authUser.sharedLinkId, allowExif);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<SharedLinkResponseDto> {
|
||||
async getById(id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
|
||||
const link = await this.shareCore.getSharedLinkById(id);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
return mapSharedLinkToResponseDto(link);
|
||||
|
||||
if (allowExif) {
|
||||
return mapSharedLink(link);
|
||||
} else {
|
||||
return mapSharedLinkWithNoExif(link);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string): Promise<string> {
|
||||
|
|
@ -77,11 +89,11 @@ export class ShareService {
|
|||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
return mapSharedLinkToResponseDto(link);
|
||||
return mapSharedLink(link);
|
||||
}
|
||||
|
||||
async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) {
|
||||
const link = await this.shareCore.updateSharedLink(id, authUser.id, dto);
|
||||
return mapSharedLinkToResponseDto(link);
|
||||
return mapSharedLink(link);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,6 @@ export class MetadataExtractionProcessor {
|
|||
async extractExifInfo(job: Job<IExifExtractionProcessor>) {
|
||||
try {
|
||||
const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
|
||||
|
||||
const exifData = await exiftool.read(asset.originalPath).catch((e) => {
|
||||
this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`);
|
||||
return null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue