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:
Jason Rasmussen
2023-01-25 11:35:28 -05:00
committed by GitHub
parent f64db3a2f9
commit 8f304b8157
70 changed files with 1481 additions and 1057 deletions

View File

@@ -1,5 +1,5 @@
import { AssetEntity } from '@app/infra';
import { AssetResponseDto } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
import { AssetResponseDto } from '@app/domain';
import fs from 'fs';
const deleteFiles = (asset: AssetEntity | AssetResponseDto) => {

View File

@@ -0,0 +1 @@
export * from './response-dto';

View File

@@ -0,0 +1,62 @@
import { AlbumEntity } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
import { AssetResponseDto, mapAsset } from '../../asset';
import { mapUser, UserResponseDto } from '../../user';
export class AlbumResponseDto {
id!: string;
ownerId!: string;
albumName!: string;
createdAt!: string;
albumThumbnailAssetId!: string | null;
shared!: boolean;
sharedUsers!: UserResponseDto[];
assets!: AssetResponseDto[];
@ApiProperty({ type: 'integer' })
assetCount!: number;
}
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((userAlbum) => {
if (userAlbum.userInfo) {
const user = mapUser(userAlbum.userInfo);
sharedUsers.push(user);
}
});
return {
albumName: entity.albumName,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: entity.createdAt,
id: entity.id,
ownerId: entity.ownerId,
sharedUsers,
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
assetCount: entity.assets?.length || 0,
};
}
export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((userAlbum) => {
if (userAlbum.userInfo) {
const user = mapUser(userAlbum.userInfo);
sharedUsers.push(user);
}
});
return {
albumName: entity.albumName,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: entity.createdAt,
id: entity.id,
ownerId: entity.ownerId,
sharedUsers,
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: [],
assetCount: entity.assets?.length || 0,
};
}

View File

@@ -0,0 +1 @@
export * from './album-response.dto';

View File

@@ -0,0 +1 @@
export * from './response-dto';

View File

@@ -0,0 +1,74 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
import { mapTag, TagResponseDto } from '../../tag';
import { ExifResponseDto, mapExif } from './exif-response.dto';
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
export class AssetResponseDto {
id!: string;
deviceAssetId!: string;
ownerId!: string;
deviceId!: string;
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type!: AssetType;
originalPath!: string;
resizePath!: string | null;
createdAt!: string;
modifiedAt!: string;
isFavorite!: boolean;
mimeType!: string | null;
duration!: string;
webpPath!: string | null;
encodedVideoPath?: string | null;
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
livePhotoVideoId?: string | null;
tags!: TagResponseDto[];
}
export function mapAsset(entity: AssetEntity): AssetResponseDto {
return {
id: entity.id,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.userId,
deviceId: entity.deviceId,
type: entity.type,
originalPath: entity.originalPath,
resizePath: entity.resizePath,
createdAt: entity.createdAt,
modifiedAt: entity.modifiedAt,
isFavorite: entity.isFavorite,
mimeType: entity.mimeType,
webpPath: entity.webpPath,
encodedVideoPath: entity.encodedVideoPath,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
};
}
export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
return {
id: entity.id,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.userId,
deviceId: entity.deviceId,
type: entity.type,
originalPath: entity.originalPath,
resizePath: entity.resizePath,
createdAt: entity.createdAt,
modifiedAt: entity.modifiedAt,
isFavorite: entity.isFavorite,
mimeType: entity.mimeType,
webpPath: entity.webpPath,
encodedVideoPath: entity.encodedVideoPath,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
};
}

View File

@@ -0,0 +1,53 @@
import { ExifEntity } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
export class ExifResponseDto {
@ApiProperty({ type: 'integer', format: 'int64' })
id?: number | null = null;
make?: string | null = null;
model?: string | null = null;
imageName?: string | null = null;
exifImageWidth?: number | null = null;
exifImageHeight?: number | null = null;
@ApiProperty({ type: 'integer', format: 'int64' })
fileSizeInByte?: number | null = null;
orientation?: string | null = null;
dateTimeOriginal?: Date | null = null;
modifyDate?: Date | null = null;
lensModel?: string | null = null;
fNumber?: number | null = null;
focalLength?: number | null = null;
iso?: number | null = null;
exposureTime?: number | null = null;
latitude?: number | null = null;
longitude?: number | null = null;
city?: string | null = null;
state?: string | null = null;
country?: string | null = null;
}
export function mapExif(entity: ExifEntity): ExifResponseDto {
return {
id: entity.id,
make: entity.make,
model: entity.model,
imageName: entity.imageName,
exifImageWidth: entity.exifImageWidth,
exifImageHeight: entity.exifImageHeight,
fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation,
dateTimeOriginal: entity.dateTimeOriginal,
modifyDate: entity.modifyDate,
lensModel: entity.lensModel,
fNumber: entity.fNumber,
focalLength: entity.focalLength,
iso: entity.iso,
exposureTime: entity.exposureTime,
latitude: entity.latitude,
longitude: entity.longitude,
city: entity.city,
state: entity.state,
country: entity.country,
};
}

View File

@@ -0,0 +1,3 @@
export * from './asset-response.dto';
export * from './exif-response.dto';
export * from './smart-info-response.dto';

View File

@@ -0,0 +1,15 @@
import { SmartInfoEntity } from '@app/infra/db/entities';
export class SmartInfoResponseDto {
id?: string;
tags?: string[] | null;
objects?: string[] | null;
}
export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto {
return {
id: entity.id,
tags: entity.tags,
objects: entity.objects,
};
}

View File

@@ -1,5 +1,6 @@
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
import { APIKeyService } from './api-key';
import { ShareService } from './share';
import { AuthService } from './auth';
import { OAuthService } from './oauth';
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
@@ -11,6 +12,7 @@ const providers: Provider[] = [
OAuthService,
SystemConfigService,
UserService,
ShareService,
{
provide: INITIAL_SYSTEM_CONFIG,

View File

@@ -1,7 +1,11 @@
export * from './album';
export * from './api-key';
export * from './asset';
export * from './auth';
export * from './domain.module';
export * from './job';
export * from './oauth';
export * from './share';
export * from './system-config';
export * from './tag';
export * from './user';

View File

@@ -25,7 +25,7 @@ export interface IVideoLengthExtractionProcessor {
}
export interface IReverseGeocodingProcessor {
exifId: string;
exifId: number;
latitude: number;
longitude: number;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,2 @@
export * from './create-shared-link.dto';
export * from './edit-shared-link.dto';

View 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';

View File

@@ -0,0 +1 @@
export * from './shared-link-response.dto';

View File

@@ -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,
};
}

View 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();
}
}
}

View 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,
});
});
});
});

View 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);
}
}

View 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>;
}

View File

@@ -0,0 +1 @@
export * from './response-dto';

View File

@@ -0,0 +1 @@
export * from './tag-response.dto';

View File

@@ -0,0 +1,26 @@
import { TagEntity, TagType } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
export class TagResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
type!: string;
name!: string;
userId!: string;
renameTagId?: string | null;
}
export function mapTag(entity: TagEntity): TagResponseDto {
return {
id: entity.id,
type: entity.type,
name: entity.name,
userId: entity.userId,
renameTagId: entity.renameTagId,
};
}

View File

@@ -1,5 +1,71 @@
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
import { AuthUserDto } from '../src';
import { AssetType, SharedLinkEntity, SharedLinkType, SystemConfig, UserEntity } from '@app/infra/db/entities';
import { AlbumResponseDto, AssetResponseDto, AuthUserDto, ExifResponseDto, SharedLinkResponseDto } from '../src';
const today = new Date();
const tomorrow = new Date();
const yesterday = new Date();
tomorrow.setDate(today.getDate() + 1);
yesterday.setDate(yesterday.getDate() - 1);
const assetInfo: ExifResponseDto = {
id: 1,
make: 'camera-make',
model: 'camera-model',
imageName: 'fancy-image',
exifImageWidth: 500,
exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: 100,
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
};
const assetResponse: AssetResponseDto = {
id: 'id_1',
deviceAssetId: 'device_asset_id_1',
ownerId: 'user_id_1',
deviceId: 'device_id_1',
type: AssetType.VIDEO,
originalPath: 'fake_path/jpeg',
resizePath: '',
createdAt: today.toISOString(),
modifiedAt: today.toISOString(),
isFavorite: false,
mimeType: 'image/jpeg',
smartInfo: {
id: 'should-be-a-number',
tags: [],
objects: ['a', 'b', 'c'],
},
webpPath: '',
encodedVideoPath: '',
duration: '0:00:00.00000',
exifInfo: assetInfo,
livePhotoVideoId: null,
tags: [],
};
const albumResponse: AlbumResponseDto = {
albumName: 'Test Album',
albumThumbnailAssetId: null,
createdAt: today.toISOString(),
id: 'album-123',
ownerId: 'admin_id',
sharedUsers: [],
shared: false,
assets: [],
assetCount: 1,
};
export const authStub = {
admin: Object.freeze<AuthUserDto>({
@@ -16,6 +82,26 @@ export const authStub = {
isPublicUser: false,
isAllowUpload: true,
}),
adminSharedLink: Object.freeze<AuthUserDto>({
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
isAllowUpload: true,
isAllowDownload: true,
isPublicUser: true,
isShowExif: true,
sharedLinkId: '123',
}),
readonlySharedLink: Object.freeze<AuthUserDto>({
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
isAllowUpload: false,
isAllowDownload: false,
isPublicUser: true,
isShowExif: true,
sharedLinkId: '123',
}),
};
export const entityStub = {
@@ -165,3 +251,175 @@ export const loginResponseStub = {
],
},
};
export const sharedLinkStub = {
valid: Object.freeze({
id: '123',
userId: authStub.admin.id,
key: Buffer.from('secret-key', 'utf8'),
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: tomorrow.toISOString(),
allowUpload: true,
allowDownload: true,
showExif: true,
album: undefined,
assets: [],
} as SharedLinkEntity),
expired: Object.freeze({
id: '123',
userId: authStub.admin.id,
key: Buffer.from('secret-key', 'utf8'),
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: yesterday.toISOString(),
allowUpload: true,
allowDownload: true,
showExif: true,
assets: [],
} as SharedLinkEntity),
readonly: Object.freeze<SharedLinkEntity>({
id: '123',
userId: authStub.admin.id,
key: Buffer.from('secret-key', 'utf8'),
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: tomorrow.toISOString(),
allowUpload: false,
allowDownload: false,
showExif: true,
assets: [],
album: {
id: 'album-123',
ownerId: authStub.admin.id,
albumName: 'Test Album',
createdAt: today.toISOString(),
albumThumbnailAssetId: null,
sharedUsers: [],
sharedLinks: [],
assets: [
{
id: 'album-asset-123',
albumId: 'album-123',
assetId: 'asset-123',
albumInfo: {} as any,
assetInfo: {
id: 'id_1',
userId: 'user_id_1',
deviceAssetId: 'device_asset_id_1',
deviceId: 'device_id_1',
type: AssetType.VIDEO,
originalPath: 'fake_path/jpeg',
resizePath: '',
createdAt: today.toISOString(),
modifiedAt: today.toISOString(),
isFavorite: false,
mimeType: 'image/jpeg',
smartInfo: {
id: 'should-be-a-number',
assetId: 'id_1',
tags: [],
objects: ['a', 'b', 'c'],
asset: null as any,
},
webpPath: '',
encodedVideoPath: '',
duration: null,
isVisible: true,
livePhotoVideoId: null,
exifInfo: {
id: 1,
assetId: 'id_1',
description: 'description',
exifImageWidth: 500,
exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
make: 'camera-make',
model: 'camera-model',
imageName: 'fancy-image',
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: 100,
fps: 100,
asset: null as any,
exifTextSearchableColumn: '',
},
tags: [],
sharedLinks: [],
},
},
],
},
}),
};
export const sharedLinkResponseStub = {
valid: Object.freeze<SharedLinkResponseDto>({
allowDownload: true,
allowUpload: true,
assets: [],
createdAt: today.toISOString(),
description: undefined,
expiresAt: tomorrow.toISOString(),
id: '123',
key: '7365637265742d6b6579',
showExif: true,
type: SharedLinkType.ALBUM,
userId: 'admin_id',
}),
expired: Object.freeze<SharedLinkResponseDto>({
album: undefined,
allowDownload: true,
allowUpload: true,
assets: [],
createdAt: today.toISOString(),
description: undefined,
expiresAt: yesterday.toISOString(),
id: '123',
key: '7365637265742d6b6579',
showExif: true,
type: SharedLinkType.ALBUM,
userId: 'admin_id',
}),
readonly: Object.freeze<SharedLinkResponseDto>({
id: '123',
userId: 'admin_id',
key: '7365637265742d6b6579',
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: tomorrow.toISOString(),
description: undefined,
allowUpload: false,
allowDownload: false,
showExif: true,
album: albumResponse,
assets: [assetResponse],
}),
readonlyNoExif: Object.freeze<SharedLinkResponseDto>({
id: '123',
userId: 'admin_id',
key: '7365637265742d6b6579',
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: tomorrow.toISOString(),
description: undefined,
allowUpload: false,
allowDownload: false,
showExif: true,
album: albumResponse,
assets: [{ ...assetResponse, exifInfo: undefined }],
}),
};
// TODO - the constructor isn't used anywhere, so not test coverage
new ExifResponseDto();

View File

@@ -2,5 +2,6 @@ export * from './api-key.repository.mock';
export * from './crypto.repository.mock';
export * from './fixtures';
export * from './job.repository.mock';
export * from './shared-link.repository.mock';
export * from './system-config.repository.mock';
export * from './user.repository.mock';

View File

@@ -0,0 +1,13 @@
import { ISharedLinkRepository } from '../src';
export const newSharedLinkRepositoryMock = (): jest.Mocked<ISharedLinkRepository> => {
return {
getAll: jest.fn(),
get: jest.fn(),
getByKey: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
hasAssetAccess: jest.fn(),
};
};

View File

@@ -7,7 +7,7 @@ import { AssetEntity } from './asset.entity';
@Entity('exif')
export class ExifEntity {
@PrimaryGeneratedColumn()
id!: string;
id!: number;
@Index({ unique: true })
@Column({ type: 'uuid' })

View File

@@ -1,2 +1,3 @@
export * from './api-key.repository';
export * from './shared-link.repository';
export * from './user.repository';

View File

@@ -0,0 +1,119 @@
import { ISharedLinkRepository } from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SharedLinkEntity } from '../entities';
@Injectable()
export class SharedLinkRepository implements ISharedLinkRepository {
readonly logger = new Logger(SharedLinkRepository.name);
constructor(
@InjectRepository(SharedLinkEntity)
private readonly repository: Repository<SharedLinkEntity>,
) {}
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
assets: {
exifInfo: true,
},
album: {
assets: {
assetInfo: {
exifInfo: true,
},
},
},
},
order: {
createdAt: 'DESC',
assets: {
createdAt: 'ASC',
},
album: {
assets: {
assetInfo: {
createdAt: 'ASC',
},
},
},
},
});
}
getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
assets: true,
album: true,
},
order: {
createdAt: 'DESC',
},
});
}
async getByKey(key: string): Promise<SharedLinkEntity | null> {
return await this.repository.findOne({
where: {
key: Buffer.from(key, 'hex'),
},
relations: {
assets: true,
album: {
assets: {
assetInfo: true,
},
},
},
order: {
createdAt: 'DESC',
},
});
}
create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity> {
return this.repository.save(entity);
}
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return this.repository.remove(entity);
}
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({ where: { id: entity.id } });
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
const count1 = await this.repository.count({
where: {
id,
assets: {
id: assetId,
},
},
});
const count2 = await this.repository.count({
where: {
id,
album: {
assets: {
assetId,
},
},
},
});
return Boolean(count1 + count2);
}
}

View File

@@ -2,6 +2,7 @@ import {
ICryptoRepository,
IJobRepository,
IKeyRepository,
ISharedLinkRepository,
ISystemConfigRepository,
IUserRepository,
QueueName,
@@ -11,10 +12,10 @@ import { BullModule } from '@nestjs/bull';
import { Global, Module, Provider } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APIKeyEntity, SharedLinkEntity, SystemConfigEntity, UserRepository } from './db';
import { APIKeyRepository, SharedLinkRepository } from './db/repository';
import { jwtConfig } from '@app/domain';
import { CryptoRepository } from './auth/crypto.repository';
import { APIKeyEntity, SystemConfigEntity, UserRepository } from './db';
import { APIKeyRepository } from './db/repository';
import { SystemConfigRepository } from './db/repository/system-config.repository';
import { JobRepository } from './job';
@@ -22,6 +23,7 @@ const providers: Provider[] = [
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: IUserRepository, useClass: UserRepository },
];
@@ -31,7 +33,7 @@ const providers: Provider[] = [
imports: [
JwtModule.register(jwtConfig),
TypeOrmModule.forRoot(databaseConfig),
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SystemConfigEntity]),
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity]),
BullModule.forRootAsync({
useFactory: async () => ({
prefix: 'immich_bull',