mirror of
https://github.com/KevinMidboe/immich.git
synced 2026-03-02 12:09:53 +00:00
refactor(server): shared links (#1385)
* refactor(server): shared links * chore: tests * fix: bugs and tests * fix: missed one expired at * fix: standardize file upload checks * test: lower flutter version Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
12
server/libs/domain/src/share/dto/create-shared-link.dto.ts
Normal file
12
server/libs/domain/src/share/dto/create-shared-link.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AlbumEntity, AssetEntity, SharedLinkType } from '@app/infra/db/entities';
|
||||
|
||||
export class CreateSharedLinkDto {
|
||||
description?: string;
|
||||
expiresAt?: string;
|
||||
type!: SharedLinkType;
|
||||
assets!: AssetEntity[];
|
||||
album?: AlbumEntity;
|
||||
allowUpload?: boolean;
|
||||
allowDownload?: boolean;
|
||||
showExif?: boolean;
|
||||
}
|
||||
18
server/libs/domain/src/share/dto/edit-shared-link.dto.ts
Normal file
18
server/libs/domain/src/share/dto/edit-shared-link.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsOptional } from 'class-validator';
|
||||
|
||||
export class EditSharedLinkDto {
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
expiresAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
allowUpload?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
allowDownload?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
showExif?: boolean;
|
||||
}
|
||||
2
server/libs/domain/src/share/dto/index.ts
Normal file
2
server/libs/domain/src/share/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './create-shared-link.dto';
|
||||
export * from './edit-shared-link.dto';
|
||||
5
server/libs/domain/src/share/index.ts
Normal file
5
server/libs/domain/src/share/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
export * from './share.core';
|
||||
export * from './share.service';
|
||||
export * from './shared-link.repository';
|
||||
1
server/libs/domain/src/share/response-dto/index.ts
Normal file
1
server/libs/domain/src/share/response-dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './shared-link-response.dto';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { SharedLinkEntity, SharedLinkType } from '@app/infra/db/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album';
|
||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset';
|
||||
|
||||
export class SharedLinkResponseDto {
|
||||
id!: string;
|
||||
description?: string;
|
||||
userId!: string;
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ enumName: 'SharedLinkType', enum: SharedLinkType })
|
||||
type!: SharedLinkType;
|
||||
createdAt!: string;
|
||||
expiresAt!: string | null;
|
||||
assets!: AssetResponseDto[];
|
||||
album?: AlbumResponseDto;
|
||||
allowUpload!: boolean;
|
||||
allowDownload!: boolean;
|
||||
showExif!: boolean;
|
||||
}
|
||||
|
||||
export function mapSharedLink(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(mapAsset),
|
||||
album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showExif: sharedLink.showExif,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
|
||||
const linkAssets = sharedLink.assets || [];
|
||||
const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo);
|
||||
|
||||
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
|
||||
|
||||
return {
|
||||
id: sharedLink.id,
|
||||
description: sharedLink.description,
|
||||
userId: sharedLink.userId,
|
||||
key: sharedLink.key.toString('hex'),
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
assets: assets.map(mapAssetWithoutExif),
|
||||
album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showExif: sharedLink.showExif,
|
||||
};
|
||||
}
|
||||
81
server/libs/domain/src/share/share.core.ts
Normal file
81
server/libs/domain/src/share/share.core.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { AssetEntity, SharedLinkEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, ForbiddenException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { CreateSharedLinkDto } from './dto';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
|
||||
export class ShareCore {
|
||||
readonly logger = new Logger(ShareCore.name);
|
||||
|
||||
constructor(private repository: ISharedLinkRepository, private cryptoRepository: ICryptoRepository) {}
|
||||
|
||||
getAll(userId: string): Promise<SharedLinkEntity[]> {
|
||||
return this.repository.getAll(userId);
|
||||
}
|
||||
|
||||
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
|
||||
return this.repository.get(userId, id);
|
||||
}
|
||||
|
||||
getByKey(key: string): Promise<SharedLinkEntity | null> {
|
||||
return this.repository.getByKey(key);
|
||||
}
|
||||
|
||||
create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
|
||||
try {
|
||||
return this.repository.create({
|
||||
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
|
||||
description: dto.description,
|
||||
userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
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,
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.error(error, error.stack);
|
||||
throw new InternalServerErrorException('failed to create shared link');
|
||||
}
|
||||
}
|
||||
|
||||
async save(userId: string, id: string, entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
|
||||
const link = await this.get(userId, id);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
|
||||
return this.repository.save({ ...entity, userId, id });
|
||||
}
|
||||
|
||||
async remove(userId: string, id: string): Promise<SharedLinkEntity> {
|
||||
const link = await this.get(userId, id);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
|
||||
return this.repository.remove(link);
|
||||
}
|
||||
|
||||
async updateAssets(userId: string, id: string, assets: AssetEntity[]) {
|
||||
const link = await this.get(userId, id);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
|
||||
return this.repository.save({ ...link, assets });
|
||||
}
|
||||
|
||||
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
|
||||
return this.repository.hasAssetAccess(id, assetId);
|
||||
}
|
||||
|
||||
checkDownloadAccess(user: AuthUserDto) {
|
||||
if (user.isPublicUser && !user.isAllowDownload) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
170
server/libs/domain/src/share/share.service.spec.ts
Normal file
170
server/libs/domain/src/share/share.service.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSharedLinkRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
sharedLinkResponseStub,
|
||||
sharedLinkStub,
|
||||
} from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { IUserRepository } from '../user';
|
||||
import { ShareService } from './share.service';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
|
||||
describe(ShareService.name, () => {
|
||||
let sut: ShareService;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let shareMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
shareMock = newSharedLinkRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new ShareService(cryptoMock, shareMock, userMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should not accept a non-existant key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(null);
|
||||
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should not accept an expired key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should not accept a key without a user', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should accept a valid key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(entityStub.admin);
|
||||
await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all keys for a user', async () => {
|
||||
shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
|
||||
await expect(sut.getAll(authStub.user1)).resolves.toEqual([
|
||||
sharedLinkResponseStub.expired,
|
||||
sharedLinkResponseStub.valid,
|
||||
]);
|
||||
expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMine', () => {
|
||||
it('should only work for a public user', async () => {
|
||||
await expect(sut.getMine(authStub.admin)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(shareMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the key for the public user (auth dto)', async () => {
|
||||
const authDto = authStub.adminSharedLink;
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should not work on a missing key', async () => {
|
||||
shareMock.get.mockResolvedValue(null);
|
||||
await expect(sut.getById(authStub.user1, sharedLinkStub.valid.id, true)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
|
||||
expect(shareMock.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.getById(authStub.user1, sharedLinkStub.valid.id, false)).resolves.toEqual(
|
||||
sharedLinkResponseStub.valid,
|
||||
);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
|
||||
});
|
||||
|
||||
it('should include exif', async () => {
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.readonly);
|
||||
await expect(sut.getById(authStub.user1, sharedLinkStub.readonly.id, true)).resolves.toEqual(
|
||||
sharedLinkResponseStub.readonly,
|
||||
);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.readonly.id);
|
||||
});
|
||||
|
||||
it('should exclude exif', async () => {
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.readonly);
|
||||
await expect(sut.getById(authStub.user1, sharedLinkStub.readonly.id, false)).resolves.toEqual(
|
||||
sharedLinkResponseStub.readonlyNoExif,
|
||||
);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.readonly.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should not work on a missing key', async () => {
|
||||
shareMock.get.mockResolvedValue(null);
|
||||
await expect(sut.remove(authStub.user1, sharedLinkStub.valid.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
|
||||
expect(shareMock.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a key', async () => {
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
|
||||
expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByKey', () => {
|
||||
it('should not work on a missing key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(null);
|
||||
await expect(sut.getByKey('secret-key')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key');
|
||||
});
|
||||
|
||||
it('should find a key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.getByKey('secret-key')).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
it('should not work on a missing key', async () => {
|
||||
shareMock.get.mockResolvedValue(null);
|
||||
await expect(sut.edit(authStub.user1, sharedLinkStub.valid.id, {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
|
||||
expect(shareMock.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should edit a key', async () => {
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
shareMock.save.mockResolvedValue(sharedLinkStub.valid);
|
||||
const dto = { allowDownload: false };
|
||||
await sut.edit(authStub.user1, sharedLinkStub.valid.id, dto);
|
||||
// await expect(sut.edit(authStub.user1, sharedLinkStub.valid.id, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
|
||||
expect(shareMock.save).toHaveBeenCalledWith({
|
||||
id: sharedLinkStub.valid.id,
|
||||
userId: authStub.user1.id,
|
||||
allowDownload: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
100
server/libs/domain/src/share/share.service.ts
Normal file
100
server/libs/domain/src/share/share.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { EditSharedLinkDto } from './dto';
|
||||
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
|
||||
import { ShareCore } from './share.core';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
readonly logger = new Logger(ShareService.name);
|
||||
private shareCore: ShareCore;
|
||||
private userCore: UserCore;
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
) {
|
||||
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
}
|
||||
|
||||
async validate(key: string): Promise<AuthUserDto> {
|
||||
const link = await this.shareCore.getByKey(key);
|
||||
if (link) {
|
||||
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
|
||||
const user = await this.userCore.get(link.userId);
|
||||
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();
|
||||
}
|
||||
|
||||
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
|
||||
const links = await this.shareCore.getAll(authUser.id);
|
||||
return links.map(mapSharedLink);
|
||||
}
|
||||
|
||||
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
|
||||
if (!authUser.isPublicUser || !authUser.sharedLinkId) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
let allowExif = true;
|
||||
if (authUser.isShowExif != undefined) {
|
||||
allowExif = authUser.isShowExif;
|
||||
}
|
||||
|
||||
return this.getById(authUser, authUser.sharedLinkId, allowExif);
|
||||
}
|
||||
|
||||
async getById(authUser: AuthUserDto, id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
|
||||
const link = await this.shareCore.get(authUser.id, id);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
|
||||
if (allowExif) {
|
||||
return mapSharedLink(link);
|
||||
} else {
|
||||
return mapSharedLinkWithNoExif(link);
|
||||
}
|
||||
}
|
||||
|
||||
async getByKey(key: string): Promise<SharedLinkResponseDto> {
|
||||
const link = await this.shareCore.getByKey(key);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
return mapSharedLink(link);
|
||||
}
|
||||
|
||||
async remove(authUser: AuthUserDto, id: string): Promise<void> {
|
||||
await this.shareCore.remove(authUser.id, id);
|
||||
}
|
||||
|
||||
async edit(authUser: AuthUserDto, id: string, dto: EditSharedLinkDto) {
|
||||
const link = await this.shareCore.save(authUser.id, id, dto);
|
||||
return mapSharedLink(link);
|
||||
}
|
||||
}
|
||||
13
server/libs/domain/src/share/shared-link.repository.ts
Normal file
13
server/libs/domain/src/share/shared-link.repository.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SharedLinkEntity } from '@app/infra/db/entities';
|
||||
|
||||
export const ISharedLinkRepository = 'ISharedLinkRepository';
|
||||
|
||||
export interface ISharedLinkRepository {
|
||||
getAll(userId: string): Promise<SharedLinkEntity[]>;
|
||||
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
|
||||
getByKey(key: string): Promise<SharedLinkEntity | null>;
|
||||
create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity>;
|
||||
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
|
||||
save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
|
||||
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user