mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
refactor(server, web): create shared link (#2879)
* refactor: shared links * chore: open api * fix: tsc error
This commit is contained in:
@@ -2,8 +2,11 @@ export const IAccessRepository = 'IAccessRepository';
|
||||
|
||||
export interface IAccessRepository {
|
||||
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
|
||||
|
||||
hasAlbumAssetAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
hasOwnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
hasSharedLinkAssetAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
|
||||
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { IKeyRepository } from '../api-key';
|
||||
import { APIKeyCore } from '../api-key/api-key.core';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { OAuthCore } from '../oauth/oauth.core';
|
||||
import { ISharedLinkRepository, SharedLinkCore } from '../shared-link';
|
||||
import { ISharedLinkRepository } from '../shared-link';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
@@ -35,7 +35,6 @@ export class AuthService {
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
private shareCore: SharedLinkCore;
|
||||
private keyCore: APIKeyCore;
|
||||
|
||||
private logger = new Logger(AuthService.name);
|
||||
@@ -45,7 +44,7 @@ export class AuthService {
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
||||
@Inject(ISharedLinkRepository) shareRepository: ISharedLinkRepository,
|
||||
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
|
||||
@Inject(IKeyRepository) keyRepository: IKeyRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG)
|
||||
initialConfig: SystemConfig,
|
||||
@@ -54,7 +53,6 @@ export class AuthService {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
this.shareCore = new SharedLinkCore(shareRepository, cryptoRepository);
|
||||
this.keyCore = new APIKeyCore(cryptoRepository, keyRepository);
|
||||
}
|
||||
|
||||
@@ -147,7 +145,7 @@ export class AuthService {
|
||||
const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string;
|
||||
|
||||
if (shareKey) {
|
||||
return this.shareCore.validate(shareKey);
|
||||
return this.validateSharedLink(shareKey);
|
||||
}
|
||||
|
||||
if (userToken) {
|
||||
@@ -193,4 +191,29 @@ export class AuthService {
|
||||
const cookies = cookieParser.parse(headers.cookie || '');
|
||||
return cookies[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
|
||||
async validateSharedLink(key: string | string[]): Promise<AuthUserDto | null> {
|
||||
key = Array.isArray(key) ? key[0] : key;
|
||||
|
||||
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
|
||||
const link = await this.sharedLinkRepository.getByKey(bytes);
|
||||
if (link) {
|
||||
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
|
||||
const user = link.user;
|
||||
if (user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: true,
|
||||
sharedLinkId: link.id,
|
||||
isAllowUpload: link.allowUpload,
|
||||
isAllowDownload: link.allowDownload,
|
||||
isShowExif: link.showExif,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new UnauthorizedException('Invalid share key');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { AlbumEntity, AssetEntity, SharedLinkType } from '@app/infra/entities';
|
||||
|
||||
export class CreateSharedLinkDto {
|
||||
description?: string;
|
||||
expiresAt?: Date;
|
||||
type!: SharedLinkType;
|
||||
assets!: AssetEntity[];
|
||||
album?: AlbumEntity;
|
||||
allowUpload?: boolean;
|
||||
allowDownload?: boolean;
|
||||
showExif?: boolean;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { IsOptional } from 'class-validator';
|
||||
|
||||
export class EditSharedLinkDto {
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@IsOptional()
|
||||
allowUpload?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
allowDownload?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
showExif?: boolean;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './create-shared-link.dto';
|
||||
export * from './edit-shared-link.dto';
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
export * from './shared-link.core';
|
||||
export * from './shared-link-response.dto';
|
||||
export * from './shared-link.dto';
|
||||
export * from './shared-link.repository';
|
||||
export * from './shared-link.service';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './shared-link-response.dto';
|
||||
@@ -1,12 +1,12 @@
|
||||
import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album';
|
||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset';
|
||||
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../album';
|
||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset';
|
||||
|
||||
export class SharedLinkResponseDto {
|
||||
id!: string;
|
||||
description?: string;
|
||||
description!: string | null;
|
||||
userId!: string;
|
||||
key!: string;
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { AssetEntity, SharedLinkEntity } from '@app/infra/entities';
|
||||
import { BadRequestException, ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { CreateSharedLinkDto } from './dto';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
|
||||
export class SharedLinkCore {
|
||||
readonly logger = new Logger(SharedLinkCore.name);
|
||||
|
||||
constructor(private repository: ISharedLinkRepository, private cryptoRepository: ICryptoRepository) {}
|
||||
|
||||
// TODO: move to SharedLinkController/SharedLinkService
|
||||
create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
|
||||
return this.repository.create({
|
||||
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
|
||||
description: dto.description,
|
||||
userId,
|
||||
createdAt: new Date(),
|
||||
expiresAt: dto.expiresAt ?? null,
|
||||
type: dto.type,
|
||||
assets: dto.assets,
|
||||
album: dto.album,
|
||||
allowUpload: dto.allowUpload ?? false,
|
||||
allowDownload: dto.allowDownload ?? true,
|
||||
showExif: dto.showExif ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async addAssets(userId: string, id: string, assets: AssetEntity[]) {
|
||||
const link = await this.repository.get(userId, id);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
|
||||
return this.repository.update({ ...link, assets: [...link.assets, ...assets] });
|
||||
}
|
||||
|
||||
async removeAssets(userId: string, id: string, assets: AssetEntity[]) {
|
||||
const link = await this.repository.get(userId, id);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
|
||||
const newAssets = link.assets.filter((asset) => assets.find((a) => a.id === asset.id));
|
||||
|
||||
return this.repository.update({ ...link, assets: newAssets });
|
||||
}
|
||||
|
||||
checkDownloadAccess(user: AuthUserDto) {
|
||||
if (user.isPublicUser && !user.isAllowDownload) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
async validate(key: string | string[]): Promise<AuthUserDto | null> {
|
||||
key = Array.isArray(key) ? key[0] : key;
|
||||
|
||||
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
|
||||
const link = await this.repository.getByKey(bytes);
|
||||
if (link) {
|
||||
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
|
||||
const user = link.user;
|
||||
if (user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: true,
|
||||
sharedLinkId: link.id,
|
||||
isAllowUpload: link.allowUpload,
|
||||
isAllowDownload: link.allowDownload,
|
||||
isShowExif: link.showExif,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new UnauthorizedException('Invalid share key');
|
||||
}
|
||||
}
|
||||
53
server/src/domain/shared-link/shared-link.dto.ts
Normal file
53
server/src/domain/shared-link/shared-link.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { SharedLinkType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsDate, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { ValidateUUID } from '../../immich/decorators/validate-uuid.decorator';
|
||||
|
||||
export class SharedLinkCreateDto {
|
||||
@IsEnum(SharedLinkType)
|
||||
@ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' })
|
||||
type!: SharedLinkType;
|
||||
|
||||
@ValidateUUID({ each: true, optional: true })
|
||||
assetIds?: string[];
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
albumId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsDate()
|
||||
@IsOptional()
|
||||
expiresAt?: Date | null = null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowUpload?: boolean = false;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowDownload?: boolean = true;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showExif?: boolean = true;
|
||||
}
|
||||
|
||||
export class SharedLinkEditDto {
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@IsOptional()
|
||||
allowUpload?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
allowDownload?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
showExif?: boolean;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export interface ISharedLinkRepository {
|
||||
getAll(userId: string): Promise<SharedLinkEntity[]>;
|
||||
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
|
||||
getByKey(key: Buffer): Promise<SharedLinkEntity | null>;
|
||||
create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
|
||||
create(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
|
||||
update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
|
||||
remove(entity: SharedLinkEntity): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { authStub, newSharedLinkRepositoryMock, sharedLinkResponseStub, sharedLinkStub } from '@test';
|
||||
import {
|
||||
albumStub,
|
||||
assetEntityStub,
|
||||
authStub,
|
||||
newAccessRepositoryMock,
|
||||
newCryptoRepositoryMock,
|
||||
newSharedLinkRepositoryMock,
|
||||
sharedLinkResponseStub,
|
||||
sharedLinkStub,
|
||||
} from '@test';
|
||||
import { when } from 'jest-when';
|
||||
import _ from 'lodash';
|
||||
import { SharedLinkType } from '../../infra/entities/shared-link.entity';
|
||||
import { AssetIdErrorReason, IAccessRepository, ICryptoRepository } from '../index';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
import { SharedLinkService } from './shared-link.service';
|
||||
|
||||
describe(SharedLinkService.name, () => {
|
||||
let sut: SharedLinkService;
|
||||
let accessMock: jest.Mocked<IAccessRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let shareMock: jest.Mocked<ISharedLinkRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
shareMock = newSharedLinkRepositoryMock();
|
||||
|
||||
sut = new SharedLinkService(shareMock);
|
||||
sut = new SharedLinkService(accessMock, cryptoMock, shareMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -64,6 +81,82 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should not allow an album shared link without an albumId', async () => {
|
||||
await expect(sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow non-owners to create album shared links', async () => {
|
||||
accessMock.hasAlbumOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should not allow individual shared links with no assets', async () => {
|
||||
await expect(
|
||||
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: [] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should require asset ownership to make an individual shared link', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create an album shared link', async () => {
|
||||
accessMock.hasAlbumOwnerAccess.mockResolvedValue(true);
|
||||
shareMock.create.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
|
||||
|
||||
expect(accessMock.hasAlbumOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
|
||||
expect(shareMock.create).toHaveBeenCalledWith({
|
||||
type: SharedLinkType.ALBUM,
|
||||
userId: authStub.admin.id,
|
||||
albumId: albumStub.oneAsset.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
assets: [],
|
||||
description: null,
|
||||
expiresAt: null,
|
||||
showExif: true,
|
||||
key: Buffer.from('random-bytes', 'utf8'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create an individual shared link', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
|
||||
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
assetIds: [assetEntityStub.image.id],
|
||||
showExif: true,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
|
||||
expect(shareMock.create).toHaveBeenCalledWith({
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
userId: authStub.admin.id,
|
||||
albumId: null,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
assets: [{ id: assetEntityStub.image.id }],
|
||||
description: null,
|
||||
expiresAt: null,
|
||||
showExif: true,
|
||||
key: Buffer.from('random-bytes', 'utf8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should throw an error for an invalid shared link', async () => {
|
||||
shareMock.get.mockResolvedValue(null);
|
||||
@@ -100,4 +193,58 @@ describe(SharedLinkService.name, () => {
|
||||
expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should not work on album shared links', async () => {
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add assets to a shared link', async () => {
|
||||
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
|
||||
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
|
||||
|
||||
when(accessMock.hasOwnerAssetAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
|
||||
when(accessMock.hasOwnerAssetAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2', 'asset-3'] }),
|
||||
).resolves.toEqual([
|
||||
{ assetId: assetEntityStub.image.id, success: false, error: AssetIdErrorReason.DUPLICATE },
|
||||
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NO_PERMISSION },
|
||||
{ assetId: 'asset-3', success: true },
|
||||
]);
|
||||
|
||||
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledTimes(2);
|
||||
expect(shareMock.update).toHaveBeenCalledWith({
|
||||
...sharedLinkStub.individual,
|
||||
assets: [assetEntityStub.image, { id: 'asset-3' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
it('should not work on album shared links', async () => {
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove assets from a shared link', async () => {
|
||||
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
|
||||
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
|
||||
|
||||
await expect(
|
||||
sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2'] }),
|
||||
).resolves.toEqual([
|
||||
{ assetId: assetEntityStub.image.id, success: true },
|
||||
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
|
||||
]);
|
||||
|
||||
expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { SharedLinkEntity } from '@app/infra/entities';
|
||||
import { AssetEntity, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { IAccessRepository } from '../access';
|
||||
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { EditSharedLinkDto } from './dto';
|
||||
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './shared-link-response.dto';
|
||||
import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
|
||||
@Injectable()
|
||||
export class SharedLinkService {
|
||||
constructor(@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository) {}
|
||||
constructor(
|
||||
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
|
||||
) {}
|
||||
|
||||
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
|
||||
getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
|
||||
return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
|
||||
}
|
||||
|
||||
@@ -30,7 +37,52 @@ export class SharedLinkService {
|
||||
return this.map(sharedLink, { withExif: true });
|
||||
}
|
||||
|
||||
async update(authUser: AuthUserDto, id: string, dto: EditSharedLinkDto) {
|
||||
async create(authUser: AuthUserDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
|
||||
switch (dto.type) {
|
||||
case SharedLinkType.ALBUM:
|
||||
if (!dto.albumId) {
|
||||
throw new BadRequestException('Invalid albumId');
|
||||
}
|
||||
|
||||
const isAlbumOwner = await this.accessRepository.hasAlbumOwnerAccess(authUser.id, dto.albumId);
|
||||
if (!isAlbumOwner) {
|
||||
throw new BadRequestException('Invalid albumId');
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SharedLinkType.INDIVIDUAL:
|
||||
if (!dto.assetIds || dto.assetIds.length === 0) {
|
||||
throw new BadRequestException('Invalid assetIds');
|
||||
}
|
||||
|
||||
for (const assetId of dto.assetIds) {
|
||||
const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
|
||||
if (!hasAccess) {
|
||||
throw new BadRequestException(`No access to assetId: ${assetId}`);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const sharedLink = await this.repository.create({
|
||||
key: this.cryptoRepository.randomBytes(50),
|
||||
userId: authUser.id,
|
||||
type: dto.type,
|
||||
albumId: dto.albumId || null,
|
||||
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
|
||||
description: dto.description || null,
|
||||
expiresAt: dto.expiresAt || null,
|
||||
allowUpload: dto.allowUpload ?? true,
|
||||
allowDownload: dto.allowDownload ?? true,
|
||||
showExif: dto.showExif ?? true,
|
||||
});
|
||||
|
||||
return this.map(sharedLink, { withExif: true });
|
||||
}
|
||||
|
||||
async update(authUser: AuthUserDto, id: string, dto: SharedLinkEditDto) {
|
||||
await this.findOrFail(authUser, id);
|
||||
const sharedLink = await this.repository.update({
|
||||
id,
|
||||
@@ -57,6 +109,60 @@ export class SharedLinkService {
|
||||
return sharedLink;
|
||||
}
|
||||
|
||||
async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
||||
const sharedLink = await this.findOrFail(authUser, id);
|
||||
|
||||
if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
|
||||
throw new BadRequestException('Invalid shared link type');
|
||||
}
|
||||
|
||||
const results: AssetIdsResponseDto[] = [];
|
||||
for (const assetId of dto.assetIds) {
|
||||
const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId);
|
||||
if (hasAsset) {
|
||||
results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
|
||||
if (!hasAccess) {
|
||||
results.push({ assetId, success: false, error: AssetIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ assetId, success: true });
|
||||
sharedLink.assets.push({ id: assetId } as AssetEntity);
|
||||
}
|
||||
|
||||
await this.repository.update(sharedLink);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async removeAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
||||
const sharedLink = await this.findOrFail(authUser, id);
|
||||
|
||||
if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
|
||||
throw new BadRequestException('Invalid shared link type');
|
||||
}
|
||||
|
||||
const results: AssetIdsResponseDto[] = [];
|
||||
for (const assetId of dto.assetIds) {
|
||||
const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId);
|
||||
if (!hasAsset) {
|
||||
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ assetId, success: true });
|
||||
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId);
|
||||
}
|
||||
|
||||
await this.repository.update(sharedLink);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
|
||||
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export class SharedLinkEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
description?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column()
|
||||
userId!: string;
|
||||
@@ -55,6 +55,9 @@ export class SharedLinkEntity {
|
||||
@Index('IDX_sharedlink_albumId')
|
||||
@ManyToOne(() => AlbumEntity, (album) => album.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
album?: AlbumEntity;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
albumId!: string | null;
|
||||
}
|
||||
|
||||
export enum SharedLinkType {
|
||||
|
||||
@@ -95,4 +95,13 @@ export class AccessRepository implements IAccessRepository {
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean> {
|
||||
return this.albumRepository.exist({
|
||||
where: {
|
||||
id: albumId,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user