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:
Alex 2023-01-21 22:15:16 -06:00 committed by GitHub
parent 4cfac47674
commit b07891089f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 348 additions and 67 deletions

View file

@ -99,7 +99,7 @@ After making any changes in the `server/libs/database/src/entities`, a database
2. Run 2. Run
```bash ```bash
npm run typeorm -- migration:generate ./libs/database/src/<migration-name> -d libs/database/src/config/database.config.ts npm run typeorm -- migration:generate ./libs/infra/src/db/<migration-name> -d ./libs/infra/src/db/config/database.config.ts
``` ```
3. Check if the migration file makes sense. 3. Check if the migration file makes sense.

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

@ -140,6 +140,8 @@ export class AlbumController {
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto, @Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
): Promise<any> { ): Promise<any> {
this.albumService.checkDownloadAccess(authUser);
const { stream, fileName, fileSize, fileCount, complete } = await this.albumService.downloadArchive( const { stream, fileName, fileSize, fileCount, complete } = await this.albumService.downloadArchive(
authUser, authUser,
albumId, albumId,

View file

@ -15,7 +15,7 @@ import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto'; import { DownloadDto } from '../asset/dto/download-library.dto';
import { ShareCore } from '../share/share.core'; import { ShareCore } from '../share/share.core';
import { ISharedLinkRepository } from '../share/shared-link.repository'; 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 { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
import _ from 'lodash'; import _ from 'lodash';
@ -210,8 +210,14 @@ export class AlbumService {
album: album, album: album,
assets: [], assets: [],
description: dto.description, description: dto.description,
allowDownload: dto.allowDownload,
showExif: dto.showExif,
}); });
return mapSharedLinkToResponseDto(sharedLink); return mapSharedLink(sharedLink);
}
checkDownloadAccess(authUser: AuthUserDto) {
this.shareCore.checkDownloadAccess(authUser);
} }
} }

View file

@ -13,6 +13,14 @@ export class CreateAlbumShareLinkDto {
@IsOptional() @IsOptional()
allowUpload?: boolean; allowUpload?: boolean;
@IsBoolean()
@IsOptional()
allowDownload?: boolean;
@IsBoolean()
@IsOptional()
showExif?: boolean;
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;

View file

@ -97,6 +97,7 @@ export class AssetController {
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto, @Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@Param('assetId') assetId: string, @Param('assetId') assetId: string,
): Promise<any> { ): Promise<any> {
this.assetService.checkDownloadAccess(authUser);
await this.assetService.checkAssetsAccess(authUser, [assetId]); await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.downloadFile(query, assetId, res); return this.assetService.downloadFile(query, assetId, res);
} }
@ -108,6 +109,7 @@ export class AssetController {
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
@Body(new ValidationPipe()) dto: DownloadFilesDto, @Body(new ValidationPipe()) dto: DownloadFilesDto,
): Promise<any> { ): Promise<any> {
this.assetService.checkDownloadAccess(authUser);
await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]); await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]);
const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto); const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto);
res.attachment(fileName); res.attachment(fileName);
@ -117,6 +119,9 @@ export class AssetController {
return stream; return stream;
} }
/**
* Current this is not used in any UI element
*/
@Authenticated({ isShared: true }) @Authenticated({ isShared: true })
@Get('/download-library') @Get('/download-library')
async downloadLibrary( async downloadLibrary(
@ -124,6 +129,7 @@ export class AssetController {
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto, @Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
): Promise<any> { ): Promise<any> {
this.assetService.checkDownloadAccess(authUser);
const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadLibrary(authUser, dto); const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadLibrary(authUser, dto);
res.attachment(fileName); res.attachment(fileName);
res.setHeader(IMMICH_CONTENT_LENGTH_HINT, fileSize); res.setHeader(IMMICH_CONTENT_LENGTH_HINT, fileSize);
@ -143,7 +149,7 @@ export class AssetController {
@Param('assetId') assetId: string, @Param('assetId') assetId: string,
): Promise<any> { ): Promise<any> {
await this.assetService.checkAssetsAccess(authUser, [assetId]); 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 }) @Authenticated({ isShared: true })
@ -246,7 +252,7 @@ export class AssetController {
@Param('assetId') assetId: string, @Param('assetId') assetId: string,
): Promise<AssetResponseDto> { ): Promise<AssetResponseDto> {
await this.assetService.checkAssetsAccess(authUser, [assetId]); 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[] = []; const deleteAssetList: AssetResponseDto[] = [];
for (const id of assetIds.ids) { for (const id of assetIds.ids) {
const assets = await this.assetService.getAssetById(id); const assets = await this.assetService.getAssetById(authUser, id);
if (!assets) { if (!assets) {
continue; continue;
} }
deleteAssetList.push(assets); deleteAssetList.push(assets);
if (assets.livePhotoVideoId) { if (assets.livePhotoVideoId) {
const livePhotoVideo = await this.assetService.getAssetById(assets.livePhotoVideoId); const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
if (livePhotoVideo) { if (livePhotoVideo) {
deleteAssetList.push(livePhotoVideo); deleteAssetList.push(livePhotoVideo);
assetIds.ids = [...assetIds.ids, livePhotoVideo.id]; assetIds.ids = [...assetIds.ids, livePhotoVideo.id];

View file

@ -23,7 +23,7 @@ import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.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 { CreateAssetDto } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.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 { ISharedLinkRepository } from '../share/shared-link.repository';
import { DownloadFilesDto } from './dto/download-files.dto'; import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.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'; import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@ -215,10 +215,15 @@ export class AssetService {
return assets.map((asset) => mapAsset(asset)); 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); const asset = await this._assetRepository.getById(assetId);
if (allowExif) {
return mapAsset(asset); return mapAsset(asset);
} else {
return mapAssetWithoutExif(asset);
}
} }
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> { 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; let fileReadStream: ReadStream;
const asset = await this._assetRepository.getById(assetId); const asset = await this._assetRepository.getById(assetId);
@ -390,7 +403,7 @@ export class AssetService {
/** /**
* Serve thumbnail image for both web and mobile app * Serve thumbnail image for both web and mobile app
*/ */
if (!query.isThumb) { if (!query.isThumb && allowOriginalFile) {
res.set({ res.set({
'Content-Type': asset.mimeType, '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> { async createAssetsSharedLink(authUser: AuthUserDto, dto: CreateAssetsShareLinkDto): Promise<SharedLinkResponseDto> {
const assets = []; const assets = [];
@ -691,9 +708,11 @@ export class AssetService {
allowUpload: dto.allowUpload, allowUpload: dto.allowUpload,
assets: assets, assets: assets,
description: dto.description, description: dto.description,
allowDownload: dto.allowDownload,
showExif: dto.showExif,
}); });
return mapSharedLinkToResponseDto(sharedLink); return mapSharedLink(sharedLink);
} }
async updateAssetsInSharedLink( async updateAssetsInSharedLink(
@ -709,7 +728,11 @@ export class AssetService {
} }
const updatedLink = await this.shareCore.updateAssetsInSharedLink(authUser.sharedLinkId, assets); const updatedLink = await this.shareCore.updateAssetsInSharedLink(authUser.sharedLinkId, assets);
return mapSharedLinkToResponseDto(updatedLink); return mapSharedLink(updatedLink);
}
getExifPermission(authUser: AuthUserDto) {
return !authUser.isPublicUser || authUser.isShowExif;
} }
} }

View file

@ -25,6 +25,14 @@ export class CreateAssetsShareLinkDto {
@IsOptional() @IsOptional()
allowUpload?: boolean; allowUpload?: boolean;
@IsBoolean()
@IsOptional()
allowDownload?: boolean;
@IsBoolean()
@IsOptional()
showExif?: boolean;
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;

View file

@ -49,3 +49,26 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
tags: entity.tags?.map(mapTag), 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),
};
}

View file

@ -8,4 +8,6 @@ export class CreateSharedLinkDto {
assets!: AssetEntity[]; assets!: AssetEntity[];
album?: AlbumEntity; album?: AlbumEntity;
allowUpload?: boolean; allowUpload?: boolean;
allowDownload?: boolean;
showExif?: boolean;
} }

View file

@ -10,6 +10,12 @@ export class EditSharedLinkDto {
@IsOptional() @IsOptional()
allowUpload?: boolean; allowUpload?: boolean;
@IsOptional()
allowDownload?: boolean;
@IsOptional()
showExif?: boolean;
@IsNotEmpty() @IsNotEmpty()
isEditExpireTime?: boolean; isEditExpireTime?: boolean;
} }

View file

@ -2,7 +2,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import _ from 'lodash'; import _ from 'lodash';
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto'; 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 { export class SharedLinkResponseDto {
id!: string; id!: string;
@ -17,9 +17,11 @@ export class SharedLinkResponseDto {
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
album?: AlbumResponseDto; album?: AlbumResponseDto;
allowUpload!: boolean; allowUpload!: boolean;
allowDownload!: boolean;
showExif!: boolean;
} }
export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): SharedLinkResponseDto { export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || []; const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo); const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo);
@ -36,5 +38,29 @@ export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): Shared
assets: assets.map(mapAsset), assets: assets.map(mapAsset),
album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined, album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, 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,
}; };
} }

View file

@ -25,7 +25,7 @@ export class ShareController {
@Authenticated() @Authenticated()
@Get(':id') @Get(':id')
getSharedLinkById(@Param('id') id: string): Promise<SharedLinkResponseDto> { getSharedLinkById(@Param('id') id: string): Promise<SharedLinkResponseDto> {
return this.shareService.getById(id); return this.shareService.getById(id, true);
} }
@Authenticated() @Authenticated()

View file

@ -2,9 +2,10 @@ import { SharedLinkEntity } from '@app/infra';
import { CreateSharedLinkDto } from './dto/create-shared-link.dto'; import { CreateSharedLinkDto } from './dto/create-shared-link.dto';
import { ISharedLinkRepository } from './shared-link.repository'; import { ISharedLinkRepository } from './shared-link.repository';
import crypto from 'node:crypto'; 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 { AssetEntity } from '@app/infra';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto'; import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
export class ShareCore { export class ShareCore {
readonly logger = new Logger(ShareCore.name); readonly logger = new Logger(ShareCore.name);
@ -24,6 +25,8 @@ export class ShareCore {
sharedLink.assets = dto.assets; sharedLink.assets = dto.assets;
sharedLink.album = dto.album; sharedLink.album = dto.album;
sharedLink.allowUpload = dto.allowUpload ?? false; sharedLink.allowUpload = dto.allowUpload ?? false;
sharedLink.allowDownload = dto.allowDownload ?? true;
sharedLink.showExif = dto.showExif ?? true;
return this.sharedLinkRepository.create(sharedLink); return this.sharedLinkRepository.create(sharedLink);
} catch (error: any) { } catch (error: any) {
@ -74,6 +77,8 @@ export class ShareCore {
link.description = dto.description ?? link.description; link.description = dto.description ?? link.description;
link.allowUpload = dto.allowUpload ?? link.allowUpload; link.allowUpload = dto.allowUpload ?? link.allowUpload;
link.allowDownload = dto.allowDownload ?? link.allowDownload;
link.showExif = dto.showExif ?? link.showExif;
if (dto.isEditExpireTime && dto.expiredAt) { if (dto.isEditExpireTime && dto.expiredAt) {
link.expiresAt = dto.expiredAt; link.expiresAt = dto.expiredAt;
@ -87,4 +92,10 @@ export class ShareCore {
async hasAssetAccess(id: string, assetId: string): Promise<boolean> { async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
return this.sharedLinkRepository.hasAssetAccess(id, assetId); return this.sharedLinkRepository.hasAssetAccess(id, assetId);
} }
checkDownloadAccess(user: AuthUserDto) {
if (user.isPublicUser && !user.isAllowDownload) {
throw new ForbiddenException();
}
}
} }

View file

@ -9,7 +9,7 @@ import {
import { UserService } from '@app/domain'; import { UserService } from '@app/domain';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto'; 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 { ShareCore } from './share.core';
import { ISharedLinkRepository } from './shared-link.repository'; import { ISharedLinkRepository } from './shared-link.repository';
@ -39,6 +39,8 @@ export class ShareService {
isPublicUser: true, isPublicUser: true,
sharedLinkId: link.id, sharedLinkId: link.id,
isAllowUpload: link.allowUpload, isAllowUpload: link.allowUpload,
isAllowDownload: link.allowDownload,
isShowExif: link.showExif,
}; };
} }
} }
@ -48,7 +50,7 @@ export class ShareService {
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> { async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
const links = await this.shareCore.getSharedLinks(authUser.id); const links = await this.shareCore.getSharedLinks(authUser.id);
return links.map(mapSharedLinkToResponseDto); return links.map(mapSharedLink);
} }
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> { async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
@ -56,15 +58,25 @@ export class ShareService {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.getById(authUser.sharedLinkId); let allowExif = true;
if (authUser.isShowExif != undefined) {
allowExif = authUser.isShowExif;
} }
async getById(id: string): Promise<SharedLinkResponseDto> { return this.getById(authUser.sharedLinkId, allowExif);
}
async getById(id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.getSharedLinkById(id); const link = await this.shareCore.getSharedLinkById(id);
if (!link) { if (!link) {
throw new BadRequestException('Shared link not found'); 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> { async remove(id: string, userId: string): Promise<string> {
@ -77,11 +89,11 @@ export class ShareService {
if (!link) { if (!link) {
throw new BadRequestException('Shared link not found'); throw new BadRequestException('Shared link not found');
} }
return mapSharedLinkToResponseDto(link); return mapSharedLink(link);
} }
async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) { async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) {
const link = await this.shareCore.updateSharedLink(id, authUser.id, dto); const link = await this.shareCore.updateSharedLink(id, authUser.id, dto);
return mapSharedLinkToResponseDto(link); return mapSharedLink(link);
} }
} }

View file

@ -139,7 +139,6 @@ export class MetadataExtractionProcessor {
async extractExifInfo(job: Job<IExifExtractionProcessor>) { async extractExifInfo(job: Job<IExifExtractionProcessor>) {
try { try {
const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data; const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
const exifData = await exiftool.read(asset.originalPath).catch((e) => { const exifData = await exiftool.read(asset.originalPath).catch((e) => {
this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`); this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`);
return null; return null;

View file

@ -736,7 +736,7 @@
"/asset/download-library": { "/asset/download-library": {
"get": { "get": {
"operationId": "downloadLibrary", "operationId": "downloadLibrary",
"description": "", "description": "Current this is not used in any UI element",
"parameters": [ "parameters": [
{ {
"name": "skip", "name": "skip",
@ -3786,6 +3786,12 @@
"allowUpload": { "allowUpload": {
"type": "boolean" "type": "boolean"
}, },
"allowDownload": {
"type": "boolean"
},
"showExif": {
"type": "boolean"
},
"description": { "description": {
"type": "string" "type": "string"
} }
@ -3887,6 +3893,12 @@
}, },
"allowUpload": { "allowUpload": {
"type": "boolean" "type": "boolean"
},
"allowDownload": {
"type": "boolean"
},
"showExif": {
"type": "boolean"
} }
}, },
"required": [ "required": [
@ -3897,7 +3909,9 @@
"createdAt", "createdAt",
"expiresAt", "expiresAt",
"assets", "assets",
"allowUpload" "allowUpload",
"allowDownload",
"showExif"
] ]
}, },
"UpdateAssetsToSharedLinkDto": { "UpdateAssetsToSharedLinkDto": {
@ -3926,6 +3940,12 @@
"allowUpload": { "allowUpload": {
"type": "boolean" "type": "boolean"
}, },
"allowDownload": {
"type": "boolean"
},
"showExif": {
"type": "boolean"
},
"isEditExpireTime": { "isEditExpireTime": {
"type": "boolean" "type": "boolean"
} }
@ -4085,6 +4105,12 @@
"allowUpload": { "allowUpload": {
"type": "boolean" "type": "boolean"
}, },
"allowDownload": {
"type": "boolean"
},
"showExif": {
"type": "boolean"
},
"description": { "description": {
"type": "string" "type": "string"
} }

View file

@ -5,4 +5,6 @@ export class AuthUserDto {
isPublicUser?: boolean; isPublicUser?: boolean;
sharedLinkId?: string; sharedLinkId?: string;
isAllowUpload?: boolean; isAllowUpload?: boolean;
isAllowDownload?: boolean;
isShowExif?: boolean;
} }

View file

@ -30,6 +30,12 @@ export class SharedLinkEntity {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
allowUpload!: boolean; allowUpload!: boolean;
@Column({ type: 'boolean', default: true })
allowDownload!: boolean;
@Column({ type: 'boolean', default: true })
showExif!: boolean;
@ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks) @ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks)
assets!: AssetEntity[]; assets!: AssetEntity[];
@ -47,4 +53,4 @@ export enum SharedLinkType {
INDIVIDUAL = 'INDIVIDUAL', INDIVIDUAL = 'INDIVIDUAL',
} }
// npm run typeorm -- migration:generate ./libs/database/src/AddSharedLinkTable -d libs/database/src/config/database.config.ts // npm run typeorm -- migration:generate ./libs/infra/src/db/AddMorePermissionToSharedLink -d ./libs/infra/src/db/config/database.config.ts

View file

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMorePermissionToSharedLink1673907194740 implements MigrationInterface {
name = 'AddMorePermissionToSharedLink1673907194740';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" ADD "allowDownload" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "shared_links" ADD "showExif" boolean NOT NULL DEFAULT true`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "showExif"`);
await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "allowDownload"`);
}
}

View file

@ -665,6 +665,18 @@ export interface CreateAlbumShareLinkDto {
* @memberof CreateAlbumShareLinkDto * @memberof CreateAlbumShareLinkDto
*/ */
'allowUpload'?: boolean; 'allowUpload'?: boolean;
/**
*
* @type {boolean}
* @memberof CreateAlbumShareLinkDto
*/
'allowDownload'?: boolean;
/**
*
* @type {boolean}
* @memberof CreateAlbumShareLinkDto
*/
'showExif'?: boolean;
/** /**
* *
* @type {string} * @type {string}
@ -696,6 +708,18 @@ export interface CreateAssetsShareLinkDto {
* @memberof CreateAssetsShareLinkDto * @memberof CreateAssetsShareLinkDto
*/ */
'allowUpload'?: boolean; 'allowUpload'?: boolean;
/**
*
* @type {boolean}
* @memberof CreateAssetsShareLinkDto
*/
'allowDownload'?: boolean;
/**
*
* @type {boolean}
* @memberof CreateAssetsShareLinkDto
*/
'showExif'?: boolean;
/** /**
* *
* @type {string} * @type {string}
@ -987,6 +1011,18 @@ export interface EditSharedLinkDto {
* @memberof EditSharedLinkDto * @memberof EditSharedLinkDto
*/ */
'allowUpload'?: boolean; 'allowUpload'?: boolean;
/**
*
* @type {boolean}
* @memberof EditSharedLinkDto
*/
'allowDownload'?: boolean;
/**
*
* @type {boolean}
* @memberof EditSharedLinkDto
*/
'showExif'?: boolean;
/** /**
* *
* @type {boolean} * @type {boolean}
@ -1612,6 +1648,18 @@ export interface SharedLinkResponseDto {
* @memberof SharedLinkResponseDto * @memberof SharedLinkResponseDto
*/ */
'allowUpload': boolean; 'allowUpload': boolean;
/**
*
* @type {boolean}
* @memberof SharedLinkResponseDto
*/
'allowDownload': boolean;
/**
*
* @type {boolean}
* @memberof SharedLinkResponseDto
*/
'showExif': boolean;
} }
/** /**
* *

View file

@ -320,6 +320,7 @@
} }
} }
} catch (e) { } catch (e) {
$downloadAssets = {};
console.error('Error downloading file ', e); console.error('Error downloading file ', e);
notificationController.show({ notificationController.show({
type: NotificationType.Error, type: NotificationType.Error,
@ -460,11 +461,13 @@
<CircleIconButton title="Remove album" on:click={removeAlbum} logo={DeleteOutline} /> <CircleIconButton title="Remove album" on:click={removeAlbum} logo={DeleteOutline} />
{/if} {/if}
{#if !isPublicShared || (isPublicShared && sharedLink?.allowDownload)}
<CircleIconButton <CircleIconButton
title="Download" title="Download"
on:click={() => downloadAlbum()} on:click={() => downloadAlbum()}
logo={FolderDownloadOutline} logo={FolderDownloadOutline}
/> />
{/if}
{#if !isPublicShared} {#if !isPublicShared}
<CircleIconButton <CircleIconButton
@ -534,11 +537,7 @@
{/if} {/if}
{#if album.assetCount > 0} {#if album.assetCount > 0}
<GalleryViewer <GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} />
assets={album.assets}
key={sharedLink?.key ?? ''}
bind:selectedAssets={multiSelectAsset}
/>
{:else} {:else}
<!-- Album is empty - Show asset selectection buttons --> <!-- Album is empty - Show asset selectection buttons -->
<section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center"> <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">

View file

@ -22,6 +22,7 @@
export let showCopyButton: boolean; export let showCopyButton: boolean;
export let showMotionPlayButton: boolean; export let showMotionPlayButton: boolean;
export let isMotionPhotoPlaying = false; export let isMotionPhotoPlaying = false;
export let showDownloadButton: boolean;
const isOwner = asset.ownerId === $page.data.user?.id; const isOwner = asset.ownerId === $page.data.user?.id;
@ -77,11 +78,14 @@
}} }}
/> />
{/if} {/if}
{#if showDownloadButton}
<CircleIconButton <CircleIconButton
logo={CloudDownloadOutline} logo={CloudDownloadOutline}
on:click={() => dispatch('download')} on:click={() => dispatch('download')}
title="Download" title="Download"
/> />
{/if}
<CircleIconButton <CircleIconButton
logo={InformationOutline} logo={InformationOutline}
on:click={() => dispatch('showDetail')} on:click={() => dispatch('showDetail')}

View file

@ -10,7 +10,13 @@
import { downloadAssets } from '$lib/stores/download'; import { downloadAssets } from '$lib/stores/download';
import VideoViewer from './video-viewer.svelte'; import VideoViewer from './video-viewer.svelte';
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte'; import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
import { api, AssetResponseDto, AssetTypeEnum, AlbumResponseDto } from '@api'; import {
api,
AssetResponseDto,
AssetTypeEnum,
AlbumResponseDto,
SharedLinkResponseDto
} from '@api';
import { import {
notificationController, notificationController,
NotificationType NotificationType
@ -22,6 +28,7 @@
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
export let publicSharedKey = ''; export let publicSharedKey = '';
export let showNavigation = true; export let showNavigation = true;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let halfLeftHover = false; let halfLeftHover = false;
@ -31,6 +38,7 @@
let isShowAlbumPicker = false; let isShowAlbumPicker = false;
let addToSharedAlbum = true; let addToSharedAlbum = true;
let shouldPlayMotionPhoto = false; let shouldPlayMotionPhoto = false;
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key); const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
onMount(async () => { onMount(async () => {
@ -166,6 +174,7 @@
}, 2000); }, 2000);
} }
} catch (e) { } catch (e) {
$downloadAssets = {};
console.error('Error downloading file ', e); console.error('Error downloading file ', e);
notificationController.show({ notificationController.show({
type: NotificationType.Error, type: NotificationType.Error,
@ -247,6 +256,7 @@
isMotionPhotoPlaying={shouldPlayMotionPhoto} isMotionPhotoPlaying={shouldPlayMotionPhoto}
showCopyButton={asset.type === AssetTypeEnum.Image} showCopyButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId} showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton}
on:goBack={closeViewer} on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler} on:showDetail={showDetailInfoHandler}
on:download={handleDownload} on:download={handleDownload}

View file

@ -136,15 +136,17 @@
/> />
{/if} {/if}
{#if sharedLink?.allowDownload}
<CircleIconButton <CircleIconButton
title="Download" title="Download"
on:click={() => downloadAssets(true)} on:click={() => downloadAssets(true)}
logo={FolderDownloadOutline} logo={FolderDownloadOutline}
/> />
{/if}
</svelte:fragment> </svelte:fragment>
</ControlAppBar> </ControlAppBar>
{/if} {/if}
<section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40"> <section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
<GalleryViewer {assets} key={sharedLink.key} bind:selectedAssets /> <GalleryViewer {assets} {sharedLink} bind:selectedAssets />
</section> </section>
</section> </section>

View file

@ -36,7 +36,7 @@
<div <div
use:clickOutside use:clickOutside
on:outclick={() => dispatch('close')} on:outclick={() => dispatch('close')}
class="bg-immich-bg dark:bg-immich-dark-gray dark:text-immich-dark-fg w-[450px] min-h-[200px] max-h-[500px] rounded-lg shadow-md" class="bg-immich-bg dark:bg-immich-dark-gray dark:text-immich-dark-fg w-[450px] min-h-[200px] max-h-[600px] rounded-lg shadow-md"
> >
<div class="flex justify-between place-items-center px-5 py-3"> <div class="flex justify-between place-items-center px-5 py-3">
<div> <div>

View file

@ -29,6 +29,8 @@
let sharedLink = ''; let sharedLink = '';
let description = ''; let description = '';
let shouldChangeExpirationTime = false; let shouldChangeExpirationTime = false;
let isAllowDownload = true;
let shouldShowExif = true;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const expiredDateOption: ImmichDropDownOption = { const expiredDateOption: ImmichDropDownOption = {
@ -42,6 +44,8 @@
description = editingLink.description; description = editingLink.description;
} }
isAllowUpload = editingLink.allowUpload; isAllowUpload = editingLink.allowUpload;
isAllowDownload = editingLink.allowDownload;
shouldShowExif = editingLink.showExif;
} }
}); });
@ -58,7 +62,9 @@
albumId: album.id, albumId: album.id,
expiredAt: expirationDate, expiredAt: expirationDate,
allowUpload: isAllowUpload, allowUpload: isAllowUpload,
description: description description: description,
allowDownload: isAllowDownload,
showExif: shouldShowExif
}); });
buildSharedLink(data); buildSharedLink(data);
} else { } else {
@ -66,7 +72,9 @@
assetIds: sharedAssets.map((a) => a.id), assetIds: sharedAssets.map((a) => a.id),
expiredAt: expirationDate, expiredAt: expirationDate,
allowUpload: isAllowUpload, allowUpload: isAllowUpload,
description: description description: description,
allowDownload: isAllowDownload,
showExif: shouldShowExif
}); });
buildSharedLink(data); buildSharedLink(data);
} }
@ -132,7 +140,9 @@
description: description, description: description,
expiredAt: expirationDate, expiredAt: expirationDate,
allowUpload: isAllowUpload, allowUpload: isAllowUpload,
isEditExpireTime: shouldChangeExpirationTime isEditExpireTime: shouldChangeExpirationTime,
allowDownload: isAllowDownload,
showExif: shouldShowExif
}); });
notificationController.show({ notificationController.show({
@ -185,12 +195,12 @@
{/if} {/if}
{/if} {/if}
<div class="mt-6 mb-2"> <div class="mt-4 mb-2">
<p class="text-xs">LINK OPTIONS</p> <p class="text-xs">LINK OPTIONS</p>
</div> </div>
<div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg"> <div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg">
<div class="flex flex-col"> <div class="flex flex-col">
<div class="mb-4"> <div class="mb-2">
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label="Description" label="Description"
@ -198,9 +208,19 @@
/> />
</div> </div>
<SettingSwitch bind:checked={isAllowUpload} title={'Allow public user to upload'} /> <div class="my-3">
<SettingSwitch bind:checked={shouldShowExif} title={'Show metadata'} />
</div>
<div class="text-sm mt-4"> <div class="my-3">
<SettingSwitch bind:checked={isAllowDownload} title={'Allow public user to download'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={isAllowUpload} title={'Allow public user to upload'} />
</div>
<div class="text-sm">
{#if editingLink} {#if editingLink}
<p class="my-2 immich-form-label"> <p class="my-2 immich-form-label">
<SettingSwitch <SettingSwitch

View file

@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetResponseDto, ThumbnailFormat } from '@api'; import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ImmichThumbnail from '../../shared-components/immich-thumbnail.svelte'; import ImmichThumbnail from '../../shared-components/immich-thumbnail.svelte';
export let assets: AssetResponseDto[]; export let assets: AssetResponseDto[];
export let key: string; export let sharedLink: SharedLinkResponseDto | undefined = undefined;
export let selectedAssets: Set<AssetResponseDto> = new Set(); export let selectedAssets: Set<AssetResponseDto> = new Set();
let isShowAssetViewer = false; let isShowAssetViewer = false;
@ -96,7 +96,7 @@
<ImmichThumbnail <ImmichThumbnail
{asset} {asset}
{thumbnailSize} {thumbnailSize}
publicSharedKey={key} publicSharedKey={sharedLink?.key}
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp} format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))} on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
on:select={selectAssetHandler} on:select={selectAssetHandler}
@ -110,7 +110,8 @@
{#if isShowAssetViewer} {#if isShowAssetViewer}
<AssetViewer <AssetViewer
asset={selectedAsset} asset={selectedAsset}
publicSharedKey={key} publicSharedKey={sharedLink?.key}
{sharedLink}
on:navigate-previous={navigateAssetBackward} on:navigate-previous={navigateAssetBackward}
on:navigate-next={navigateAssetForward} on:navigate-next={navigateAssetForward}
on:close={closeViewer} on:close={closeViewer}

View file

@ -122,12 +122,28 @@
</div> </div>
</div> </div>
<div class="info-bottom"> <div class="info-bottom flex gap-4">
{#if link.allowUpload} {#if link.allowUpload}
<div
class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[80px]"
>
Upload
</div>
{/if}
{#if link.allowDownload}
<div <div
class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[100px]" class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[100px]"
> >
Allow upload Download
</div>
{/if}
{#if link.showExif}
<div
class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[60px]"
>
EXIF
</div> </div>
{/if} {/if}
</div> </div>