refactor(server, web): create shared link (#2879)

* refactor: shared links

* chore: open api

* fix: tsc error
This commit is contained in:
Jason Rasmussen
2023-06-20 21:08:43 -04:00
committed by GitHub
parent 746ca5d5ed
commit 868f629f32
61 changed files with 2150 additions and 2642 deletions
@@ -1,5 +1,5 @@
import { AlbumResponseDto } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Response } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Put, Query, Response } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { handleDownload } from '../../app.utils';
@@ -10,7 +10,6 @@ import { UseValidation } from '../../decorators/use-validation.decorator';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { AlbumService } from './album.service';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@@ -59,9 +58,4 @@ export class AlbumController {
) {
return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res));
}
@Post('create-shared-link')
createAlbumSharedLink(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumSharedLinkDto) {
return this.service.createSharedLink(authUser, dto);
}
}
@@ -1,7 +1,7 @@
import { AlbumResponseDto, ICryptoRepository, ISharedLinkRepository, mapUser } from '@app/domain';
import { AlbumResponseDto, mapUser } from '@app/domain';
import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { newCryptoRepositoryMock, newSharedLinkRepositoryMock, userEntityStub } from '@test';
import { userEntityStub } from '@test';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { IAlbumRepository } from './album-repository';
@@ -11,9 +11,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
describe('Album service', () => {
let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
const authUser: AuthUserDto = Object.freeze({
id: '1111',
@@ -99,20 +97,11 @@ describe('Album service', () => {
updateThumbnails: jest.fn(),
};
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
downloadServiceMock = {
downloadArchive: jest.fn(),
};
cryptoMock = newCryptoRepositoryMock();
sut = new AlbumService(
albumRepositoryMock,
sharedLinkRepositoryMock,
downloadServiceMock as DownloadService,
cryptoMock,
);
sut = new AlbumService(albumRepositoryMock, downloadServiceMock as DownloadService);
});
it('gets an owned album', async () => {
@@ -1,36 +1,22 @@
import {
AlbumResponseDto,
ICryptoRepository,
ISharedLinkRepository,
mapAlbum,
mapSharedLink,
SharedLinkCore,
SharedLinkResponseDto,
} from '@app/domain';
import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
import { AlbumResponseDto, mapAlbum } from '@app/domain';
import { AlbumEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { IAlbumRepository } from './album-repository';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@Injectable()
export class AlbumService {
readonly logger = new Logger(AlbumService.name);
private shareCore: SharedLinkCore;
private logger = new Logger(AlbumService.name);
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
private downloadService: DownloadService,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
) {
this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
}
) {}
private async _getAlbum({
authUser,
@@ -91,7 +77,7 @@ export class AlbumService {
}
async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) {
this.shareCore.checkDownloadAccess(authUser);
this.checkDownloadAccess(authUser);
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0);
@@ -99,20 +85,9 @@ export class AlbumService {
return this.downloadService.downloadArchive(album.albumName, assets);
}
async createSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise<SharedLinkResponseDto> {
const album = await this._getAlbum({ authUser, albumId: dto.albumId });
const sharedLink = await this.shareCore.create(authUser.id, {
type: SharedLinkType.ALBUM,
expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload,
album,
assets: [],
description: dto.description,
allowDownload: dto.allowDownload,
showExif: dto.showExif,
});
return mapSharedLink(sharedLink);
private checkDownloadAccess(authUser: AuthUserDto) {
if (authUser.isPublicUser && !authUser.isAllowDownload) {
throw new ForbiddenException();
}
}
}
@@ -1,35 +0,0 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator';
export class CreateAlbumShareLinkDto {
@ValidateUUID()
albumId!: string;
@IsOptional()
@IsDate()
@Type(() => Date)
@ApiProperty()
expiresAt?: Date;
@IsBoolean()
@IsOptional()
@ApiProperty()
allowUpload?: boolean;
@IsBoolean()
@IsOptional()
@ApiProperty()
allowDownload?: boolean;
@IsBoolean()
@IsOptional()
@ApiProperty()
showExif?: boolean;
@IsString()
@IsOptional()
@ApiProperty()
description?: string;
}
@@ -1,4 +1,4 @@
import { AssetResponseDto, ImmichReadStream, SharedLinkResponseDto } from '@app/domain';
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import {
Body,
Controller,
@@ -10,7 +10,6 @@ import {
HttpStatus,
Param,
ParseFilePipe,
Patch,
Post,
Put,
Query,
@@ -28,15 +27,12 @@ import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { AssetService } from './asset.service';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DeviceIdDto } from './dto/device-id.dto';
@@ -319,30 +315,4 @@ export class AssetController {
): Promise<AssetBulkUploadCheckResponseDto> {
return this.assetService.bulkUploadCheck(authUser, dto);
}
@Post('/shared-link')
createAssetsSharedLink(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: CreateAssetsShareLinkDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.createAssetsSharedLink(authUser, dto);
}
@SharedLinkRoute()
@Patch('/shared-link/add')
addAssetsToSharedLink(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: AddAssetsDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.addAssetsToSharedLink(authUser, dto);
}
@SharedLinkRoute()
@Patch('/shared-link/remove')
removeAssetsFromSharedLink(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: RemoveAssetsDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.removeAssetsFromSharedLink(authUser, dto);
}
}
@@ -1,31 +1,19 @@
import {
IAccessRepository,
ICryptoRepository,
IJobRepository,
ISharedLinkRepository,
IStorageRepository,
JobName,
} from '@app/domain';
import { IAccessRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { ForbiddenException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
fileStub,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newSharedLinkRepositoryMock,
newStorageRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '@test';
import { when } from 'jest-when';
import { QueryFailedError, Repository } from 'typeorm';
import { DownloadService } from '../../modules/download/download.service';
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
@@ -134,8 +122,6 @@ describe('AssetService', () => {
let accessMock: jest.Mocked<IAccessRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
@@ -165,9 +151,7 @@ describe('AssetService', () => {
};
accessMock = newAccessRepositoryMock();
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(
@@ -175,9 +159,7 @@ describe('AssetService', () => {
assetRepositoryMock,
a,
downloadServiceMock as DownloadService,
sharedLinkRepositoryMock,
jobMock,
cryptoMock,
storageMock,
);
@@ -189,77 +171,6 @@ describe('AssetService', () => {
.mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
});
describe('createAssetsSharedLink', () => {
it('should create an individual share link', async () => {
const asset1 = _getAsset_1();
const dto: CreateAssetsShareLinkDto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.user1.id, asset1.id);
});
});
describe('updateAssetsInSharedLink', () => {
it('should require a valid shared link', async () => {
const asset1 = _getAsset_1();
const authDto = authStub.adminSharedLink;
const dto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(null);
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
await expect(sut.addAssetsToSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.update).not.toHaveBeenCalled();
});
it('should add assets to a shared link', async () => {
const asset1 = _getAsset_1();
const authDto = authStub.adminSharedLink;
const dto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.update.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssetsToSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.update).toHaveBeenCalled();
});
it('should remove assets from a shared link', async () => {
const asset1 = _getAsset_1();
const authDto = authStub.adminSharedLink;
const dto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.update.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssetsFromSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.update).toHaveBeenCalled();
});
});
describe('uploadFile', () => {
it('should handle a file upload', async () => {
const assetEntity = _getAsset_1();
@@ -2,19 +2,14 @@ import {
AssetResponseDto,
getLivePhotoMotionFilename,
IAccessRepository,
ICryptoRepository,
IJobRepository,
ImmichReadStream,
ISharedLinkRepository,
IStorageRepository,
JobName,
mapAsset,
mapAssetWithoutExif,
mapSharedLink,
SharedLinkCore,
SharedLinkResponseDto,
} from '@app/domain';
import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
import { AssetEntity, AssetType } from '@app/infra/entities';
import {
BadRequestException,
ForbiddenException,
@@ -33,15 +28,12 @@ import { QueryFailedError, Repository } from 'typeorm';
import { promisify } from 'util';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
@@ -80,22 +72,17 @@ interface ServableFile {
@Injectable()
export class AssetService {
readonly logger = new Logger(AssetService.name);
private shareCore: SharedLinkCore;
private assetCore: AssetCore;
constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
private downloadService: DownloadService,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
}
public async uploadFile(
@@ -608,61 +595,9 @@ export class AssetService {
}
private checkDownloadAccess(authUser: AuthUserDto) {
this.shareCore.checkDownloadAccess(authUser);
}
async createAssetsSharedLink(authUser: AuthUserDto, dto: CreateAssetsShareLinkDto): Promise<SharedLinkResponseDto> {
const assets = [];
await this.checkAssetsAccess(authUser, dto.assetIds);
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assets.push(asset);
}
const sharedLink = await this.shareCore.create(authUser.id, {
type: SharedLinkType.INDIVIDUAL,
expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload,
assets,
description: dto.description,
allowDownload: dto.allowDownload,
showExif: dto.showExif,
});
return mapSharedLink(sharedLink);
}
async addAssetsToSharedLink(authUser: AuthUserDto, dto: AddAssetsDto): Promise<SharedLinkResponseDto> {
if (!authUser.sharedLinkId) {
if (authUser.isPublicUser && !authUser.isAllowDownload) {
throw new ForbiddenException();
}
const assets = [];
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assets.push(asset);
}
const updatedLink = await this.shareCore.addAssets(authUser.id, authUser.sharedLinkId, assets);
return mapSharedLink(updatedLink);
}
async removeAssetsFromSharedLink(authUser: AuthUserDto, dto: RemoveAssetsDto): Promise<SharedLinkResponseDto> {
if (!authUser.sharedLinkId) {
throw new ForbiddenException();
}
const assets = [];
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assets.push(asset);
}
const updatedLink = await this.shareCore.removeAssets(authUser.id, authUser.sharedLinkId, assets);
return mapSharedLink(updatedLink);
}
getExifPermission(authUser: AuthUserDto) {
@@ -1,41 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAssetsShareLinkDto {
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ApiProperty({
isArray: true,
type: String,
title: 'Array asset IDs to be shared',
example: [
'bf973405-3f2a-48d2-a687-2ed4167164be',
'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
],
})
assetIds!: string[];
@IsDate()
@Type(() => Date)
@IsOptional()
expiresAt?: Date;
@IsBoolean()
@IsOptional()
allowUpload?: boolean;
@IsBoolean()
@IsOptional()
allowDownload?: boolean;
@IsBoolean()
@IsOptional()
showExif?: boolean;
@IsString()
@IsOptional()
description?: string;
}
@@ -1,13 +1,21 @@
import { AuthUserDto, EditSharedLinkDto, SharedLinkResponseDto, SharedLinkService } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch } from '@nestjs/common';
import {
AssetIdsDto,
AssetIdsResponseDto,
AuthUserDto,
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkResponseDto,
SharedLinkService,
} from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUser } from '../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('share')
@Controller('share')
@ApiTags('Shared Link')
@Controller('shared-link')
@Authenticated()
@UseValidation()
export class SharedLinkController {
@@ -29,11 +37,16 @@ export class SharedLinkController {
return this.service.get(authUser, id);
}
@Post()
createSharedLink(@AuthUser() authUser: AuthUserDto, @Body() dto: SharedLinkCreateDto) {
return this.service.create(authUser, dto);
}
@Patch(':id')
updateSharedLink(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: EditSharedLinkDto,
@Body() dto: SharedLinkEditDto,
): Promise<SharedLinkResponseDto> {
return this.service.update(authUser, id, dto);
}
@@ -42,4 +55,24 @@ export class SharedLinkController {
removeSharedLink(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(authUser, id);
}
@SharedLinkRoute()
@Put(':id/assets')
addSharedLinkAssets(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetIdsDto,
): Promise<AssetIdsResponseDto[]> {
return this.service.addAssets(authUser, id, dto);
}
@SharedLinkRoute()
@Delete(':id/assets')
removeSharedLinkAssets(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetIdsDto,
): Promise<AssetIdsResponseDto[]> {
return this.service.removeAssets(authUser, id, dto);
}
}