refactor(server)*: tsconfigs (#2689)

* refactor(server): tsconfigs

* chore: dummy commit

* fix: start.sh

* chore: restore original entry scripts
This commit is contained in:
Jason Rasmussen
2023-06-08 11:01:07 -04:00
committed by GitHub
parent a2130aa6c5
commit 8ebac41318
465 changed files with 209 additions and 332 deletions

View File

@@ -0,0 +1,164 @@
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { dataSource } from '@app/infra/database.config';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository {
get(albumId: string): Promise<AlbumEntity | null>;
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
updateThumbnails(): Promise<number | undefined>;
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>;
}
export const IAlbumRepository = 'IAlbumRepository';
@Injectable()
export class AlbumRepository implements IAlbumRepository {
constructor(
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
) {}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
const sharedAlbums = await this.albumRepository.count({ where: { sharedUsers: { id: userId } } });
const sharedAlbumCount = ownedAlbums.filter((album) => album.sharedUsers?.length > 0).length;
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount);
}
async get(albumId: string): Promise<AlbumEntity | null> {
return this.albumRepository.findOne({
where: { id: albumId },
relations: {
owner: true,
sharedUsers: true,
assets: {
exifInfo: true,
},
sharedLinks: true,
},
order: {
assets: {
fileCreatedAt: 'ASC',
},
},
});
}
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<number> {
const assetCount = album.assets.length;
album.assets = album.assets.filter((asset) => {
return !removeAssetsDto.assetIds.includes(asset.id);
});
const numRemovedAssets = assetCount - album.assets.length;
if (numRemovedAssets > 0) {
album.updatedAt = new Date();
}
await this.albumRepository.save(album, {});
return numRemovedAssets;
}
async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto> {
const alreadyExisting: string[] = [];
for (const assetId of addAssetsDto.assetIds) {
// Album already contains that asset
if (album.assets?.some((a) => a.id === assetId)) {
alreadyExisting.push(assetId);
continue;
}
album.assets.push({ id: assetId } as AssetEntity);
}
// Add album thumbnail if not exist.
if (!album.albumThumbnailAssetId && album.assets.length > 0) {
album.albumThumbnailAssetId = album.assets[0].id;
}
const successfullyAdded = addAssetsDto.assetIds.length - alreadyExisting.length;
if (successfullyAdded > 0) {
album.updatedAt = new Date();
}
await this.albumRepository.save(album);
return {
successfullyAdded,
alreadyInAlbum: alreadyExisting,
};
}
/**
* Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets
* - Removing references of thumbnails to assets outside the album
* - Setting a thumbnail when none is set and the album contains assets
*
* @returns Amount of updated album thumbnails or undefined when unknown
*/
async updateThumbnails(): Promise<number | undefined> {
// Subquery for getting a new thumbnail.
const newThumbnail = this.assetRepository
.createQueryBuilder('assets')
.select('albums_assets2.assetsId')
.addFrom('albums_assets_assets', 'albums_assets2')
.where('albums_assets2.assetsId = assets.id')
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
.orderBy('assets.fileCreatedAt', 'DESC')
.limit(1);
// Using dataSource, because there is no direct access to albums_assets_assets.
const albumHasAssets = dataSource
.createQueryBuilder()
.select('1')
.from('albums_assets_assets', 'albums_assets')
.where('"albums"."id" = "albums_assets"."albumsId"');
const albumContainsThumbnail = albumHasAssets
.clone()
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
const updateAlbums = this.albumRepository
.createQueryBuilder('albums')
.update(AlbumEntity)
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
const result = await updateAlbums.execute();
return result.affected;
}
async getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number> {
return this.albumRepository.count({
where: [
{
ownerId: userId,
assets: {
id: assetId,
},
},
{
sharedUsers: {
id: userId,
},
assets: {
id: assetId,
},
},
],
});
}
}

View File

@@ -0,0 +1,73 @@
import { Controller, Get, Post, Body, Param, Delete, Put, Query, Response } from '@nestjs/common';
import { AlbumService } from './album.service';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from '@app/domain';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { Response as Res } from 'express';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
import { UseValidation } from '../../decorators/use-validation.decorator';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { handleDownload } from '../../app.utils';
@ApiTags('Album')
@Controller('album')
@Authenticated()
@UseValidation()
export class AlbumController {
constructor(private readonly service: AlbumService) {}
@Get('count-by-user-id')
getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.service.getCountByUserId(authUser);
}
@SharedLinkRoute()
@Put(':id/assets')
addAssetsToAlbum(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AddAssetsDto,
): Promise<AddAssetsResponseDto> {
// TODO: Handle nonexistent assetIds.
// TODO: Disallow adding assets of another user to an album.
return this.service.addAssets(authUser, id, dto);
}
@SharedLinkRoute()
@Get(':id')
getAlbumInfo(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.get(authUser, id);
}
@Delete(':id/assets')
removeAssetFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@Body() dto: RemoveAssetsDto,
@Param() { id }: UUIDParamDto,
): Promise<AlbumResponseDto> {
return this.service.removeAssets(authUser, id, dto);
}
@SharedLinkRoute()
@Get(':id/download')
@ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } })
downloadArchive(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Query() dto: DownloadDto,
@Response({ passthrough: true }) res: Res,
) {
return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res));
}
@Post('create-shared-link')
createAlbumSharedLink(@GetAuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumSharedLinkDto) {
return this.service.createSharedLink(authUser, dto);
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AlbumService } from './album.service';
import { AlbumController } from './album.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { AlbumRepository, IAlbumRepository } from './album-repository';
import { DownloadModule } from '../../modules/download/download.module';
const ALBUM_REPOSITORY_PROVIDER = {
provide: IAlbumRepository,
useClass: AlbumRepository,
};
@Module({
imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity]), DownloadModule],
controllers: [AlbumController],
providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER],
exports: [ALBUM_REPOSITORY_PROVIDER],
})
export class AlbumModule {}

View File

@@ -0,0 +1,278 @@
import { AlbumService } from './album.service';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository';
import { DownloadService } from '../../modules/download/download.service';
import { ISharedLinkRepository } from '@app/domain';
import { newCryptoRepositoryMock, newSharedLinkRepositoryMock, userEntityStub } from '@test';
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',
email: 'auth@test.com',
isAdmin: false,
});
const albumOwner: UserEntity = Object.freeze({
...authUser,
firstName: 'auth',
lastName: 'user',
createdAt: new Date('2022-06-19T23:41:36.910Z'),
deletedAt: null,
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
profileImagePath: '',
shouldChangePassword: false,
oauthId: '',
tags: [],
assets: [],
storageLabel: null,
});
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222';
const sharedAlbumSharedAlsoWithId = '3333';
const _getOwnedAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = albumOwner.id;
albumEntity.owner = albumOwner;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.updatedAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.sharedUsers = [];
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
albumEntity.sharedLinks = [];
return albumEntity;
};
const _getSharedWithAuthUserAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = sharedAlbumOwnerId;
albumEntity.owner = albumOwner;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
albumEntity.sharedUsers = [
{
...userEntityStub.user1,
id: authUser.id,
},
{
...userEntityStub.user1,
id: sharedAlbumSharedAlsoWithId,
},
];
albumEntity.sharedLinks = [];
return albumEntity;
};
const _getNotOwnedNotSharedAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = '5555';
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.sharedUsers = [];
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
return albumEntity;
};
beforeAll(() => {
albumRepositoryMock = {
addAssets: jest.fn(),
get: jest.fn(),
removeAssets: jest.fn(),
updateThumbnails: jest.fn(),
getCountByUserId: jest.fn(),
getSharedWithUserAlbumCount: jest.fn(),
};
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
downloadServiceMock = {
downloadArchive: jest.fn(),
};
cryptoMock = newCryptoRepositoryMock();
sut = new AlbumService(
albumRepositoryMock,
sharedLinkRepositoryMock,
downloadServiceMock as DownloadService,
cryptoMock,
);
});
it('gets an owned album', async () => {
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
const expectedResult: AlbumResponseDto = {
ownerId: albumOwner.id,
owner: mapUser(albumOwner),
id: albumId,
albumName: 'name',
createdAt: new Date('2022-06-19T23:41:36.910Z'),
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
sharedUsers: [],
assets: [],
albumThumbnailAssetId: null,
shared: false,
assetCount: 0,
};
await expect(sut.get(authUser, albumId)).resolves.toEqual(expectedResult);
});
it('gets a shared album', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
const result = await sut.get(authUser, albumId);
expect(result.id).toEqual(albumId);
expect(result.ownerId).toEqual(sharedAlbumOwnerId);
expect(result.shared).toEqual(true);
expect(result.sharedUsers).toHaveLength(2);
expect(result.sharedUsers[0].id).toEqual(authUser.id);
expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId);
});
it('prevents retrieving an album that is not owned or shared', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(sut.get(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
});
it('throws a not found exception if the album is not found', async () => {
albumRepositoryMock.get.mockImplementation(() => Promise.resolve(null));
await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
});
it('adds assets to owned album', async () => {
const albumEntity = _getOwnedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1,
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto;
// TODO: stub and expect album rendered
expect(result.album?.id).toEqual(albumId);
});
it('adds assets to shared album (shared with auth user)', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1,
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto;
// TODO: stub and expect album rendered
expect(result.album?.id).toEqual(albumId);
});
it('prevents adding assets to a not owned / shared album', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1,
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
await expect(sut.addAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
});
// it('removes assets from owned album', async () => {
// const albumEntity = _getOwnedAlbum();
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// await expect(
// sut.removeAssetsFromAlbum(
// authUser,
// {
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
// },
// albumEntity.id,
// ),
// ).resolves.toBeUndefined();
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
// });
// });
// it('removes assets from shared album (shared with auth user)', async () => {
// const albumEntity = _getOwnedSharedAlbum();
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// await expect(
// sut.removeAssetsFromAlbum(
// authUser,
// {
// assetIds: ['1'],
// },
// albumEntity.id,
// ),
// ).resolves.toBeUndefined();
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
// assetIds: ['1'],
// });
// });
it('prevents removing assets from a not owned / shared album', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1,
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
await expect(sut.removeAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -0,0 +1,122 @@
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumResponseDto, mapAlbum } from '@app/domain';
import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { AddAssetsDto } from './dto/add-assets.dto';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto';
import {
SharedLinkCore,
ISharedLinkRepository,
mapSharedLink,
SharedLinkResponseDto,
ICryptoRepository,
} from '@app/domain';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
@Injectable()
export class AlbumService {
readonly logger = new Logger(AlbumService.name);
private shareCore: SharedLinkCore;
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,
albumId,
validateIsOwner = true,
}: {
authUser: AuthUserDto;
albumId: string;
validateIsOwner?: boolean;
}): Promise<AlbumEntity> {
await this.albumRepository.updateThumbnails();
const album = await this.albumRepository.get(albumId);
if (!album) {
throw new NotFoundException('Album Not Found');
}
const isOwner = album.ownerId == authUser.id;
if (validateIsOwner && !isOwner) {
throw new ForbiddenException('Unauthorized Album Access');
} else if (!isOwner && !album.sharedUsers?.some((user) => user.id == authUser.id)) {
throw new ForbiddenException('Unauthorized Album Access');
}
return album;
}
async get(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
return mapAlbum(album);
}
async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId });
const deletedCount = await this.albumRepository.removeAssets(album, dto);
const newAlbum = await this._getAlbum({ authUser, albumId });
if (deletedCount !== dto.assetIds.length) {
throw new BadRequestException('Some assets were not found in the album');
}
return mapAlbum(newAlbum);
}
async addAssets(authUser: AuthUserDto, albumId: string, dto: AddAssetsDto): Promise<AddAssetsResponseDto> {
if (authUser.isPublicUser && !authUser.isAllowUpload) {
this.logger.warn('Deny public user attempt to add asset to album');
throw new ForbiddenException('Public user is not allowed to upload');
}
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const result = await this.albumRepository.addAssets(album, dto);
const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
return {
...result,
album: mapAlbum(newAlbum),
};
}
async getCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.albumRepository.getCountByUserId(authUser.id);
}
async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) {
this.shareCore.checkDownloadAccess(authUser);
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0);
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);
}
}

View File

@@ -0,0 +1,6 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
export class AddAssetsDto {
@ValidateUUID({ each: true })
assetIds!: string[];
}

View File

@@ -0,0 +1,6 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
export class AddUsersDto {
@ValidateUUID({ each: true })
sharedUserIds!: string[];
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
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;
}

View File

@@ -0,0 +1,6 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
export class RemoveAssetsDto {
@ValidateUUID({ each: true })
assetIds!: string[];
}

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { AlbumResponseDto } from '@app/domain';
export class AddAssetsResponseDto {
@ApiProperty({ type: 'integer' })
successfullyAdded!: number;
@ApiProperty()
alreadyInAlbum!: string[];
@ApiProperty()
album?: AlbumResponseDto;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
export class AlbumCountResponseDto {
@ApiProperty({ type: 'integer' })
owned!: number;
@ApiProperty({ type: 'integer' })
shared!: number;
@ApiProperty({ type: 'integer' })
sharing!: number;
constructor(owned: number, shared: number, sharing: number) {
this.owned = owned;
this.shared = shared;
this.sharing = sharing;
}
}

View File

@@ -0,0 +1,387 @@
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/repository/Repository';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto, TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { In } from 'typeorm/find-options/operator/In';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { IsNull, Not } from 'typeorm';
import { AssetSearchDto } from './dto/asset-search.dto';
export interface AssetCheck {
id: string;
checksum: Buffer;
}
export interface IAssetRepository {
get(id: string): Promise<AssetEntity | null>;
create(
asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'livePhotoVideoId'>,
): Promise<AssetEntity>;
remove(asset: AssetEntity): Promise<void>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
getAll(): Promise<AssetEntity[]>;
getAllVideos(): Promise<AssetEntity[]>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>;
getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]>;
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetCountByTimeBucket(userId: string, dto: GetAssetCountByTimeBucketDto): Promise<AssetCountByTimeBucket[]>;
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getArchivedAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
countByIdAndUser(assetId: string, userId: string): Promise<number>;
}
export const IAssetRepository = 'IAssetRepository';
@Injectable()
export class AssetRepository implements IAssetRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
) {}
async getAllVideos(): Promise<AssetEntity[]> {
return await this.assetRepository.find({
where: { type: AssetType.VIDEO },
});
}
async getAll(): Promise<AssetEntity[]> {
return await this.assetRepository.find({
where: { isVisible: true },
relations: {
exifInfo: true,
smartInfo: true,
},
});
}
async getAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
// Get asset count by AssetType
const items = await this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.where('"ownerId" = :ownerId', { ownerId: ownerId })
.andWhere('asset.isVisible = true')
.groupBy('asset.type')
.getRawMany();
return this.getAssetCount(items);
}
async getArchivedAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
// Get archived asset count by AssetType
const items = await this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.where('"ownerId" = :ownerId', { ownerId: ownerId })
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = true')
.groupBy('asset.type')
.getRawMany();
return this.getAssetCount(items);
}
async getAssetByTimeBucket(userId: string, dto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
// Get asset entity from a list of time buckets
let builder = this.assetRepository
.createQueryBuilder('asset')
.where('asset.ownerId = :userId', { userId: userId })
.andWhere(`date_trunc('month', "fileCreatedAt") IN (:...buckets)`, {
buckets: [...dto.timeBucket],
})
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false')
.orderBy('asset.fileCreatedAt', 'DESC');
if (!dto.withoutThumbs) {
builder = builder.andWhere('asset.resizePath is not NULL');
}
return builder.getMany();
}
async getAssetCountByTimeBucket(
userId: string,
dto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucket[]> {
const builder = this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)::int`, 'count')
.where('"ownerId" = :userId', { userId: userId })
.andWhere('asset.isVisible = true')
.andWhere('asset.isArchived = false');
// Using a parameter for this doesn't work https://github.com/typeorm/typeorm/issues/7308
if (dto.timeGroup === TimeGroupEnum.Month) {
builder
.addSelect(`date_trunc('month', "fileCreatedAt")`, 'timeBucket')
.groupBy(`date_trunc('month', "fileCreatedAt")`)
.orderBy(`date_trunc('month', "fileCreatedAt")`, 'DESC');
} else if (dto.timeGroup === TimeGroupEnum.Day) {
builder
.addSelect(`date_trunc('day', "fileCreatedAt")`, 'timeBucket')
.groupBy(`date_trunc('day', "fileCreatedAt")`)
.orderBy(`date_trunc('day', "fileCreatedAt")`, 'DESC');
}
if (!dto.withoutThumbs) {
builder.andWhere('asset.resizePath is not NULL');
}
return builder.getRawMany();
}
async getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.where('asset.ownerId = :userId', { userId: userId })
.andWhere('asset.isVisible = true')
.leftJoin('asset.exifInfo', 'ei')
.leftJoin('asset.smartInfo', 'si')
.select('si.tags', 'tags')
.addSelect('si.objects', 'objects')
.addSelect('asset.type', 'assetType')
.addSelect('ei.orientation', 'orientation')
.addSelect('ei."lensModel"', 'lensModel')
.addSelect('ei.make', 'make')
.addSelect('ei.model', 'model')
.addSelect('ei.city', 'city')
.addSelect('ei.state', 'state')
.addSelect('ei.country', 'country')
.distinctOn(['si.tags'])
.getRawMany();
}
async getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]> {
return await this.assetRepository.query(
`
SELECT DISTINCT ON (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
FROM assets a
LEFT JOIN smart_info si ON a.id = si."assetId"
WHERE a."ownerId" = $1
AND a."isVisible" = true
AND si.objects IS NOT NULL
`,
[userId],
);
}
async getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]> {
return await this.assetRepository.query(
`
SELECT DISTINCT ON (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
FROM assets a
LEFT JOIN exif e ON a.id = e."assetId"
WHERE a."ownerId" = $1
AND a."isVisible" = true
AND e.city IS NOT NULL
AND a.type = 'IMAGE';
`,
[userId],
);
}
/**
* Get a single asset information by its ID
* - include exif info
* @param assetId
*/
async getById(assetId: string): Promise<AssetEntity> {
return await this.assetRepository.findOneOrFail({
where: {
id: assetId,
},
relations: {
exifInfo: true,
tags: true,
sharedLinks: true,
smartInfo: true,
faces: {
person: true,
},
},
});
}
/**
* Get all assets belong to the user on the database
* @param ownerId
*/
async getAllByUserId(ownerId: string, dto: AssetSearchDto): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
ownerId,
resizePath: dto.withoutThumbs ? undefined : Not(IsNull()),
isVisible: true,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived,
},
relations: {
exifInfo: true,
tags: true,
},
skip: dto.skip || 0,
order: {
fileCreatedAt: 'DESC',
},
});
}
get(id: string): Promise<AssetEntity | null> {
return this.assetRepository.findOne({
where: { id },
relations: {
faces: {
person: true,
},
},
});
}
async create(
asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'livePhotoVideoId'>,
): Promise<AssetEntity> {
return this.assetRepository.save(asset);
}
async remove(asset: AssetEntity): Promise<void> {
await this.assetRepository.remove(asset);
}
async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
const { id } = await this.assetRepository.save(asset);
return this.assetRepository.findOneOrFail({ where: { id } });
}
/**
* Update asset
*/
async update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
asset.isArchived = dto.isArchived ?? asset.isArchived;
if (asset.exifInfo != null) {
asset.exifInfo.description = dto.description || '';
await this.exifRepository.save(asset.exifInfo);
} else {
const exifInfo = new ExifEntity();
exifInfo.description = dto.description || '';
exifInfo.asset = asset;
await this.exifRepository.save(exifInfo);
asset.exifInfo = exifInfo;
}
await this.assetRepository.update(asset.id, {
isFavorite: asset.isFavorite,
isArchived: asset.isArchived,
});
return this.assetRepository.findOneOrFail({
where: {
id: asset.id,
},
});
}
/**
* Get assets by device's Id on the database
* @param ownerId
* @param deviceId
*
* @returns Promise<string[]> - Array of assetIds belong to the device
*/
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
const items = await this.assetRepository.find({
select: { deviceAssetId: true },
where: {
ownerId,
deviceId,
isVisible: true,
},
});
return items.map((asset) => asset.deviceAssetId);
}
/**
* Get assets by checksums on the database
* @param ownerId
* @param checksums
*
*/
async getAssetsByChecksums(ownerId: string, checksums: Buffer[]): Promise<AssetCheck[]> {
return this.assetRepository.find({
select: {
id: true,
checksum: true,
},
where: {
ownerId,
checksum: In(checksums),
},
});
}
async getExistingAssets(ownerId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]> {
const assets = await this.assetRepository.find({
select: { deviceAssetId: true },
where: {
deviceAssetId: In(checkDuplicateAssetDto.deviceAssetIds),
deviceId: checkDuplicateAssetDto.deviceId,
ownerId,
},
});
return assets.map((asset) => asset.deviceAssetId);
}
async countByIdAndUser(assetId: string, ownerId: string): Promise<number> {
return await this.assetRepository.count({
where: {
id: assetId,
ownerId,
},
});
}
private getAssetCount(items: any): AssetCountByUserIdResponseDto {
const assetCountByUserId = new AssetCountByUserIdResponseDto();
// asset type to dto property mapping
const map: Record<AssetType, keyof AssetCountByUserIdResponseDto> = {
[AssetType.AUDIO]: 'audio',
[AssetType.IMAGE]: 'photos',
[AssetType.VIDEO]: 'videos',
[AssetType.OTHER]: 'other',
};
for (const item of items) {
const count = Number(item.count) || 0;
const assetType = item.type as AssetType;
const type = map[assetType];
assetCountByUserId[type] = count;
assetCountByUserId.total += count;
}
return assetCountByUserId;
}
}

View File

@@ -0,0 +1,345 @@
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import {
Controller,
Post,
UseInterceptors,
Body,
Get,
Param,
ValidationPipe,
Query,
Response,
Headers,
Delete,
HttpCode,
Header,
Put,
UploadedFiles,
Patch,
StreamableFile,
ParseFilePipe,
} from '@nestjs/common';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { DownloadDto } from './dto/download-library.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { SharedLinkResponseDto } from '@app/domain';
import { AssetSearchDto } from './dto/asset-search.dto';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { DeviceIdDto } from './dto/device-id.dto';
import { handleDownload } from '../../app.utils';
function asStreamableFile({ stream, type, length }: ImmichReadStream) {
return new StreamableFile(stream, { type, length });
}
interface UploadFiles {
assetData: ImmichFile[];
livePhotoData?: ImmichFile[];
sidecarData: ImmichFile[];
}
@ApiTags('Asset')
@Controller('asset')
@Authenticated()
export class AssetController {
constructor(private assetService: AssetService) {}
@SharedLinkRoute()
@Post('upload')
@UseInterceptors(
FileFieldsInterceptor(
[
{ name: 'assetData', maxCount: 1 },
{ name: 'livePhotoData', maxCount: 1 },
{ name: 'sidecarData', maxCount: 1 },
],
assetUploadOption,
),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Asset Upload Information',
type: CreateAssetDto,
})
async uploadFile(
@GetAuthUser() authUser: AuthUserDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@Body(new ValidationPipe()) dto: CreateAssetDto,
@Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> {
const file = mapToUploadFile(files.assetData[0]);
const _livePhotoFile = files.livePhotoData?.[0];
const _sidecarFile = files.sidecarData?.[0];
let livePhotoFile;
if (_livePhotoFile) {
livePhotoFile = mapToUploadFile(_livePhotoFile);
}
let sidecarFile;
if (_sidecarFile) {
sidecarFile = mapToUploadFile(_sidecarFile);
}
const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile, sidecarFile);
if (responseDto.duplicate) {
res.status(200);
}
return responseDto;
}
@SharedLinkRoute()
@Get('/download/:id')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadFile(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.assetService.downloadFile(authUser, id).then(asStreamableFile);
}
@SharedLinkRoute()
@Post('/download-files')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadFiles(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Body(new ValidationPipe()) dto: DownloadFilesDto,
) {
return this.assetService.downloadFiles(authUser, dto).then((download) => handleDownload(download, res));
}
/**
* Current this is not used in any UI element
*/
@SharedLinkRoute()
@Get('/download-library')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadLibrary(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
@Response({ passthrough: true }) res: Res,
) {
return this.assetService.downloadLibrary(authUser, dto).then((download) => handleDownload(download, res));
}
@SharedLinkRoute()
@Get('/file/:id')
@Header('Cache-Control', 'max-age=31536000')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
serveFile(
@GetAuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@Param() { id }: UUIDParamDto,
) {
return this.assetService.serveFile(authUser, id, query, res, headers);
}
@SharedLinkRoute()
@Get('/thumbnail/:id')
@Header('Cache-Control', 'max-age=31536000')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
getAssetThumbnail(
@GetAuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
) {
return this.assetService.getAssetThumbnail(authUser, id, query, res, headers);
}
@Get('/curated-objects')
getCuratedObjects(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> {
return this.assetService.getCuratedObject(authUser);
}
@Get('/curated-locations')
getCuratedLocations(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> {
return this.assetService.getCuratedLocation(authUser);
}
@Get('/search-terms')
getAssetSearchTerms(@GetAuthUser() authUser: AuthUserDto): Promise<string[]> {
return this.assetService.getAssetSearchTerm(authUser);
}
@Post('/search')
searchAsset(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: SearchAssetDto,
): Promise<AssetResponseDto[]> {
return this.assetService.searchAsset(authUser, dto);
}
@Post('/count-by-time-bucket')
getAssetCountByTimeBucket(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucketResponseDto> {
return this.assetService.getAssetCountByTimeBucket(authUser, dto);
}
@Get('/count-by-user-id')
getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getAssetCountByUserId(authUser);
}
@Get('/stat/archive')
getArchivedAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getArchivedAssetCountByUserId(authUser);
}
/**
* Get all AssetEntity belong to the user
*/
@Get('/')
@ApiHeader({
name: 'if-none-match',
description: 'ETag of data already cached on the client',
required: false,
schema: { type: 'string' },
})
getAllAssets(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
): Promise<AssetResponseDto[]> {
return this.assetService.getAllAssets(authUser, dto);
}
@Post('/time-bucket')
getAssetByTimeBucket(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: GetAssetByTimeBucketDto,
): Promise<AssetResponseDto[]> {
return this.assetService.getAssetByTimeBucket(authUser, dto);
}
/**
* Get all asset of a device that are in the database, ID only.
*/
@Get('/:deviceId')
getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param() { deviceId }: DeviceIdDto) {
return this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
}
/**
* Get a single asset's information
*/
@SharedLinkRoute()
@Get('/assetById/:id')
getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.assetService.getAssetById(authUser, id);
}
/**
* Update an asset
*/
@Put('/:id')
updateAsset(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body(ValidationPipe) dto: UpdateAssetDto,
): Promise<AssetResponseDto> {
return this.assetService.updateAsset(authUser, id, dto);
}
@Delete('/')
deleteAsset(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: DeleteAssetDto,
): Promise<DeleteAssetResponseDto[]> {
return this.assetService.deleteAll(authUser, dto);
}
/**
* Check duplicated asset before uploading - for Web upload used
*/
@SharedLinkRoute()
@Post('/check')
@HttpCode(200)
checkDuplicateAsset(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: CheckDuplicateAssetDto,
): Promise<CheckDuplicateAssetResponseDto> {
return this.assetService.checkDuplicatedAsset(authUser, dto);
}
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('/exist')
@HttpCode(200)
checkExistingAssets(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this.assetService.checkExistingAssets(authUser, dto);
}
/**
* Checks if assets exist by checksums
*/
@Post('/bulk-upload-check')
@HttpCode(200)
bulkUploadCheck(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> {
return this.assetService.bulkUploadCheck(authUser, dto);
}
@Post('/shared-link')
createAssetsSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: CreateAssetsShareLinkDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.createAssetsSharedLink(authUser, dto);
}
@SharedLinkRoute()
@Patch('/shared-link/add')
addAssetsToSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: AddAssetsDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.addAssetsToSharedLink(authUser, dto);
}
@SharedLinkRoute()
@Patch('/shared-link/remove')
removeAssetsFromSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: RemoveAssetsDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.removeAssetsFromSharedLink(authUser, dto);
}
}

View File

@@ -0,0 +1,53 @@
import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
import { IAssetRepository } from './asset-repository';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { parse } from 'node:path';
export class AssetCore {
constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
async create(
authUser: AuthUserDto,
dto: CreateAssetDto,
file: UploadFile,
livePhotoAssetId?: string,
sidecarFile?: UploadFile,
): Promise<AssetEntity> {
const asset = await this.repository.create({
owner: { id: authUser.id } as UserEntity,
mimeType: file.mimeType,
checksum: file.checksum,
originalPath: file.originalPath,
deviceAssetId: dto.deviceAssetId,
deviceId: dto.deviceId,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
type: dto.assetType,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived ?? false,
duration: dto.duration || null,
isVisible: dto.isVisible ?? true,
livePhotoVideo: livePhotoAssetId != null ? ({ id: livePhotoAssetId } as AssetEntity) : null,
resizePath: null,
webpPath: null,
encodedVideoPath: null,
tags: [],
sharedLinks: [],
originalFileName: parse(file.originalName).name,
faces: [],
sidecarPath: sidecarFile?.originalPath || null,
});
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
if (asset.type === AssetType.VIDEO) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
}
return asset;
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { AssetService } from './asset.service';
import { AssetController } from './asset.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { AssetRepository, IAssetRepository } from './asset-repository';
import { DownloadModule } from '../../modules/download/download.module';
import { AlbumModule } from '../album/album.module';
const ASSET_REPOSITORY_PROVIDER = {
provide: IAssetRepository,
useClass: AssetRepository,
};
@Module({
imports: [
//
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
DownloadModule,
AlbumModule,
],
controllers: [AssetController],
providers: [AssetService, ASSET_REPOSITORY_PROVIDER],
exports: [ASSET_REPOSITORY_PROVIDER],
})
export class AssetModule {}

View File

@@ -0,0 +1,544 @@
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { QueryFailedError, Repository } from 'typeorm';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { DownloadService } from '../../modules/download/download.service';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import {
IAccessRepository,
ICryptoRepository,
IJobRepository,
ISharedLinkRepository,
IStorageRepository,
JobName,
} from '@app/domain';
import {
assetEntityStub,
authStub,
fileStub,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newSharedLinkRepositoryMock,
newStorageRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '@test';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { BadRequestException } from '@nestjs/common';
import { when } from 'jest-when';
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto();
createAssetDto.deviceAssetId = 'deviceAssetId';
createAssetDto.deviceId = 'deviceId';
createAssetDto.assetType = AssetType.OTHER;
createAssetDto.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
createAssetDto.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
createAssetDto.isFavorite = false;
createAssetDto.isArchived = false;
createAssetDto.duration = '0:00:00.000000';
return createAssetDto;
};
const _getAsset_1 = () => {
const asset_1 = new AssetEntity();
asset_1.id = 'id_1';
asset_1.ownerId = 'user_id_1';
asset_1.deviceAssetId = 'device_asset_id_1';
asset_1.deviceId = 'device_id_1';
asset_1.type = AssetType.VIDEO;
asset_1.originalPath = 'fake_path/asset_1.jpeg';
asset_1.resizePath = '';
asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.isFavorite = false;
asset_1.isArchived = false;
asset_1.mimeType = 'image/jpeg';
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000';
asset_1.exifInfo = new ExifEntity();
asset_1.exifInfo.latitude = 49.533547;
asset_1.exifInfo.longitude = 10.703075;
return asset_1;
};
const _getAsset_2 = () => {
const asset_2 = new AssetEntity();
asset_2.id = 'id_2';
asset_2.ownerId = 'user_id_1';
asset_2.deviceAssetId = 'device_asset_id_2';
asset_2.deviceId = 'device_id_1';
asset_2.type = AssetType.VIDEO;
asset_2.originalPath = 'fake_path/asset_2.jpeg';
asset_2.resizePath = '';
asset_2.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.updatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.isFavorite = false;
asset_2.isArchived = false;
asset_2.mimeType = 'image/jpeg';
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
asset_2.duration = '0:00:00.000000';
return asset_2;
};
const _getAssets = () => {
return [_getAsset_1(), _getAsset_2()];
};
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
const result1 = new AssetCountByTimeBucket();
result1.count = 2;
result1.timeBucket = '2022-06-01T00:00:00.000Z';
const result2 = new AssetCountByTimeBucket();
result1.count = 5;
result1.timeBucket = '2022-07-01T00:00:00.000Z';
return [result1, result2];
};
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
const result = new AssetCountByUserIdResponseDto();
result.videos = 2;
result.photos = 2;
return result;
};
const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => {
const result = new AssetCountByUserIdResponseDto();
result.videos = 1;
result.photos = 2;
return result;
};
describe('AssetService', () => {
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: jest.Mocked<IAccessRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
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>;
beforeEach(() => {
assetRepositoryMock = {
get: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
update: jest.fn(),
getAll: jest.fn(),
getAllVideos: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetsByChecksums: jest.fn(),
getAssetCountByUserId: jest.fn(),
getArchivedAssetCountByUserId: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
};
albumRepositoryMock = {
getSharedWithUserAlbumCount: jest.fn(),
} as unknown as jest.Mocked<AlbumRepository>;
downloadServiceMock = {
downloadArchive: jest.fn(),
};
accessMock = newAccessRepositoryMock();
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(
accessMock,
assetRepositoryMock,
albumRepositoryMock,
a,
downloadServiceMock as DownloadService,
sharedLinkRepositoryMock,
jobMock,
cryptoMock,
storageMock,
);
when(assetRepositoryMock.get)
.calledWith(assetEntityStub.livePhotoStillAsset.id)
.mockResolvedValue(assetEntityStub.livePhotoStillAsset);
when(assetRepositoryMock.get)
.calledWith(assetEntityStub.livePhotoMotionAsset.id)
.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);
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.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();
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const dto = _getCreateAssetDto();
assetRepositoryMock.create.mockResolvedValue(assetEntity);
assetRepositoryMock.save.mockResolvedValue(assetEntity);
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
it('should handle a duplicate', async () => {
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] },
});
expect(storageMock.moveFile).not.toHaveBeenCalled();
});
it('should handle a live photo', async () => {
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockResolvedValueOnce(assetEntityStub.livePhotoMotionAsset);
assetRepositoryMock.save.mockResolvedValueOnce(assetEntityStub.livePhotoMotionAsset);
assetRepositoryMock.create.mockResolvedValueOnce(assetEntityStub.livePhotoStillAsset);
assetRepositoryMock.save.mockResolvedValueOnce(assetEntityStub.livePhotoStillAsset);
await expect(
sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion),
).resolves.toEqual({
duplicate: false,
id: 'live-photo-still-asset',
});
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.METADATA_EXTRACTION,
data: { id: assetEntityStub.livePhotoMotionAsset.id, source: 'upload' },
},
],
[{ name: JobName.VIDEO_CONVERSION, data: { id: assetEntityStub.livePhotoMotionAsset.id } }],
[{ name: JobName.METADATA_EXTRACTION, data: { id: assetEntityStub.livePhotoStillAsset.id, source: 'upload' } }],
]);
});
});
it('get assets by device id', async () => {
const assets = _getAssets();
assetRepositoryMock.getAllByDeviceId.mockImplementation(() =>
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
);
const deviceId = 'device_id_1';
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
expect(result.length).toEqual(2);
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
});
it('get assets count by time bucket', async () => {
const assetCountByTimeBucket = _getAssetCountByTimeBucket();
assetRepositoryMock.getAssetCountByTimeBucket.mockImplementation(() =>
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
);
const result = await sut.getAssetCountByTimeBucket(authStub.user1, {
timeGroup: TimeGroupEnum.Month,
});
expect(result.totalCount).toEqual(assetCountByTimeBucket.reduce((a, b) => a + b.count, 0));
expect(result.buckets.length).toEqual(2);
});
it('get asset count by user id', async () => {
const assetCount = _getAssetCountByUserId();
assetRepositoryMock.getAssetCountByUserId.mockResolvedValue(assetCount);
await expect(sut.getAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount);
});
it('get archived asset count by user id', async () => {
const assetCount = _getArchivedAssetsCountByUserId();
assetRepositoryMock.getArchivedAssetCountByUserId.mockResolvedValue(assetCount);
await expect(sut.getArchivedAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount);
});
describe('deleteAll', () => {
it('should return failed status when an asset is missing', async () => {
assetRepositoryMock.get.mockResolvedValue(null);
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
]);
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should return failed status a delete fails', async () => {
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
assetRepositoryMock.remove.mockRejectedValue('delete failed');
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
]);
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should delete a live photo', async () => {
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
await expect(sut.deleteAll(authStub.user1, { ids: [assetEntityStub.livePhotoStillAsset.id] })).resolves.toEqual([
{ id: assetEntityStub.livePhotoStillAsset.id, status: 'SUCCESS' },
{ id: assetEntityStub.livePhotoMotionAsset.id, status: 'SUCCESS' },
]);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: {
files: [
'fake_path/asset_1.jpeg',
undefined,
undefined,
undefined,
undefined,
'fake_path/asset_1.mp4',
undefined,
undefined,
undefined,
undefined,
],
},
});
});
it('should delete a batch of assets', async () => {
const asset1 = {
id: 'asset1',
originalPath: 'original-path-1',
resizePath: 'resize-path-1',
webpPath: 'web-path-1',
};
const asset2 = {
id: 'asset2',
originalPath: 'original-path-2',
resizePath: 'resize-path-2',
webpPath: 'web-path-2',
encodedVideoPath: 'encoded-video-path-2',
};
when(assetRepositoryMock.get)
.calledWith(asset1.id)
.mockResolvedValue(asset1 as AssetEntity);
when(assetRepositoryMock.get)
.calledWith(asset2.id)
.mockResolvedValue(asset2 as AssetEntity);
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
{ id: 'asset1', status: 'SUCCESS' },
{ id: 'asset2', status: 'SUCCESS' },
]);
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset1'] } }],
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset2'] } }],
[
{
name: JobName.DELETE_FILES,
data: {
files: [
'original-path-1',
'web-path-1',
'resize-path-1',
undefined,
undefined,
'original-path-2',
'web-path-2',
'resize-path-2',
'encoded-video-path-2',
undefined,
],
},
},
],
]);
});
});
// describe('checkDownloadAccess', () => {
// it('should validate download access', async () => {
// await sut.checkDownloadAccess(authStub.adminSharedLink);
// });
// it('should not allow when user is not allowed to download', async () => {
// expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
// });
// });
describe('downloadFile', () => {
it('should download a single file', async () => {
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
assetRepositoryMock.get.mockResolvedValue(_getAsset_1());
await sut.downloadFile(authStub.admin, 'id_1');
expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
});
});
describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([
{ id: 'asset-1', checksum: file1 },
{ id: 'asset-2', checksum: file2 },
]);
await expect(
sut.bulkUploadCheck(authStub.admin, {
assets: [
{ id: '1', checksum: file1.toString('hex') },
{ id: '2', checksum: file2.toString('base64') },
],
}),
).resolves.toEqual({
results: [
{ id: '1', assetId: 'asset-1', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
{ id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
],
});
expect(assetRepositoryMock.getAssetsByChecksums).toHaveBeenCalledWith(authStub.admin.id, [file1, file2]);
});
});
});

View File

@@ -0,0 +1,736 @@
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
StreamableFile,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { QueryFailedError, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
import { constants, createReadStream, stat } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import {
AssetResponseDto,
getLivePhotoMotionFilename,
IAccessRepository,
ImmichReadStream,
IStorageRepository,
JobName,
mapAsset,
mapAssetWithoutExif,
} from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { IAssetRepository } from './asset-repository';
import { SearchPropertiesDto } from './dto/search-properties.dto';
import {
AssetCountByTimeBucketResponseDto,
mapAssetCountByTimeBucket,
} from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { AssetCore } from './asset.core';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { ICryptoRepository, IJobRepository } from '@app/domain';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository';
import { SharedLinkCore } from '@app/domain';
import { ISharedLinkRepository } from '@app/domain';
import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
import { AssetSearchDto } from './dto/asset-search.dto';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import {
AssetUploadAction,
AssetRejectReason,
AssetBulkUploadCheckResponseDto,
} from './response-dto/asset-check-response.dto';
const fileInfo = promisify(stat);
interface ServableFile {
filepath: string;
contentType: string;
}
@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,
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
@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(
authUser: AuthUserDto,
dto: CreateAssetDto,
file: UploadFile,
livePhotoFile?: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetFileUploadResponseDto> {
if (livePhotoFile) {
livePhotoFile = {
...livePhotoFile,
originalName: getLivePhotoMotionFilename(file.originalName, livePhotoFile.originalName),
};
}
let livePhotoAsset: AssetEntity | null = null;
try {
if (livePhotoFile) {
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
}
const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile);
return { id: asset.id, duplicate: false };
} catch (error: any) {
// clean up files
await this.jobRepository.queue({
name: JobName.DELETE_FILES,
data: { files: [file.originalPath, livePhotoFile?.originalPath, sidecarFile?.originalPath] },
});
// handle duplicates with a success response
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
const checksums = [file.checksum, livePhotoFile?.checksum].filter((checksum): checksum is Buffer => !!checksum);
const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, checksums);
return { id: duplicate.id, duplicate: true };
}
this.logger.error(`Error uploading file ${error}`, error?.stack);
throw new BadRequestException(`Error uploading file`, `${error}`);
}
}
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
}
public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
if (dto.userId && dto.userId !== authUser.id) {
await this.checkUserAccess(authUser, dto.userId);
}
const assets = await this._assetRepository.getAllByUserId(dto.userId || authUser.id, dto);
return assets.map((asset) => mapAsset(asset));
}
public async getAssetByTimeBucket(
authUser: AuthUserDto,
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,
): Promise<AssetResponseDto[]> {
if (getAssetByTimeBucketDto.userId) {
await this.checkUserAccess(authUser, getAssetByTimeBucketDto.userId);
}
const assets = await this._assetRepository.getAssetByTimeBucket(
getAssetByTimeBucketDto.userId || authUser.id,
getAssetByTimeBucketDto,
);
return assets.map((asset) => mapAsset(asset));
}
public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
await this.checkAssetsAccess(authUser, [assetId]);
const allowExif = this.getExifPermission(authUser);
const asset = await this._assetRepository.getById(assetId);
if (allowExif) {
return mapAsset(asset);
} else {
return mapAssetWithoutExif(asset);
}
}
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.checkAssetsAccess(authUser, [assetId], true);
const asset = await this._assetRepository.getById(assetId);
if (!asset) {
throw new BadRequestException('Asset not found');
}
const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [assetId] } });
return mapAsset(updatedAsset);
}
public async downloadLibrary(authUser: AuthUserDto, dto: DownloadDto) {
this.checkDownloadAccess(authUser);
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
return this.downloadService.downloadArchive(dto.name || `library`, assets);
}
public async downloadFiles(authUser: AuthUserDto, dto: DownloadFilesDto) {
this.checkDownloadAccess(authUser);
await this.checkAssetsAccess(authUser, [...dto.assetIds]);
const assetToDownload = [];
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assetToDownload.push(asset);
// Get live photo asset
if (asset.livePhotoVideoId) {
const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId);
assetToDownload.push(livePhotoAsset);
}
}
const now = new Date().toISOString();
return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload);
}
public async downloadFile(authUser: AuthUserDto, assetId: string): Promise<ImmichReadStream> {
this.checkDownloadAccess(authUser);
await this.checkAssetsAccess(authUser, [assetId]);
try {
const asset = await this._assetRepository.get(assetId);
if (asset && asset.originalPath && asset.mimeType) {
return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType);
}
} catch (e) {
Logger.error(`Error download asset ${e}`, 'downloadFile');
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
}
throw new NotFoundException();
}
async getAssetThumbnail(
authUser: AuthUserDto,
assetId: string,
query: GetAssetThumbnailDto,
res: Res,
headers: Record<string, string>,
) {
await this.checkAssetsAccess(authUser, [assetId]);
const asset = await this._assetRepository.get(assetId);
if (!asset) {
throw new NotFoundException('Asset not found');
}
try {
const thumbnailPath = this.getThumbnailPath(asset, query.format);
return this.streamFile(thumbnailPath, res, headers);
} catch (e) {
res.header('Cache-Control', 'none');
Logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
throw new InternalServerErrorException(
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
{ cause: e as Error },
);
}
}
public async serveFile(
authUser: AuthUserDto,
assetId: string,
query: ServeFileDto,
res: Res,
headers: Record<string, string>,
) {
await this.checkAssetsAccess(authUser, [assetId]);
const allowOriginalFile = !!(!authUser.isPublicUser || authUser.isAllowDownload);
const asset = await this._assetRepository.getById(assetId);
if (!asset) {
throw new NotFoundException('Asset does not exist');
}
// Handle Sending Images
if (asset.type == AssetType.IMAGE) {
try {
const { filepath, contentType } = this.getServePath(asset, query, allowOriginalFile);
return this.streamFile(filepath, res, headers, contentType);
} catch (e) {
Logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]');
throw new InternalServerErrorException(
e,
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
);
}
} else {
try {
// Handle Video
let videoPath = asset.originalPath;
let mimeType = asset.mimeType;
await fs.access(videoPath, constants.R_OK | constants.W_OK);
if (asset.encodedVideoPath) {
videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
}
const { size } = await fileInfo(videoPath);
const range = headers.range;
if (range) {
/** Extracting Start and End value from Range Header */
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
let start = parseInt(startStr, 10);
let end = endStr ? parseInt(endStr, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
end = size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = size - end;
end = size - 1;
}
// Handle unavailable range request
if (start >= size || end >= size) {
console.error('Bad Request');
// Return the 416 Range Not Satisfiable.
res.status(416).set({ 'Content-Range': `bytes */${size}` });
throw new BadRequestException('Bad Request Range');
}
/** Sending Partial Content With HTTP Code 206 */
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mimeType,
});
const videoStream = createReadStream(videoPath, { start, end });
return new StreamableFile(videoStream);
}
return this.streamFile(videoPath, res, headers, mimeType);
} catch (e) {
this.logger.error(`Error serving VIDEO asset=${asset.id}`);
throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile');
}
}
}
public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
await this.checkAssetsAccess(authUser, dto.ids, true);
const deleteQueue: Array<string | null> = [];
const result: DeleteAssetResponseDto[] = [];
const ids = dto.ids.slice();
for (const id of ids) {
const asset = await this._assetRepository.get(id);
if (!asset) {
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
continue;
}
try {
if (asset.faces) {
await Promise.all(
asset.faces.map(({ assetId, personId }) =>
this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }),
),
);
}
await this._assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
deleteQueue.push(
asset.originalPath,
asset.webpPath,
asset.resizePath,
asset.encodedVideoPath,
asset.sidecarPath,
);
// TODO refactor this to use cascades
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
ids.push(asset.livePhotoVideoId);
}
} catch {
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
}
}
if (deleteQueue.length > 0) {
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: deleteQueue } });
}
return result;
}
async getAssetSearchTerm(authUser: AuthUserDto): Promise<string[]> {
const possibleSearchTerm = new Set<string>();
const rows = await this._assetRepository.getSearchPropertiesByUserId(authUser.id);
rows.forEach((row: SearchPropertiesDto) => {
// tags
row.tags?.map((tag: string) => possibleSearchTerm.add(tag?.toLowerCase()));
// objects
row.objects?.map((object: string) => possibleSearchTerm.add(object?.toLowerCase()));
// asset's tyoe
possibleSearchTerm.add(row.assetType?.toLowerCase() || '');
// image orientation
possibleSearchTerm.add(row.orientation?.toLowerCase() || '');
// Lens model
possibleSearchTerm.add(row.lensModel?.toLowerCase() || '');
// Make and model
possibleSearchTerm.add(row.make?.toLowerCase() || '');
possibleSearchTerm.add(row.model?.toLowerCase() || '');
// Location
possibleSearchTerm.add(row.city?.toLowerCase() || '');
possibleSearchTerm.add(row.state?.toLowerCase() || '');
possibleSearchTerm.add(row.country?.toLowerCase() || '');
});
return Array.from(possibleSearchTerm).filter((x) => x != null && x != '');
}
async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto): Promise<AssetResponseDto[]> {
const query = `
SELECT a.*
FROM assets a
LEFT JOIN smart_info si ON a.id = si."assetId"
LEFT JOIN exif e ON a.id = e."assetId"
WHERE a."ownerId" = $1
AND
(
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
TO_TSVECTOR('english', ARRAY_TO_STRING(si.objects, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
e."exifTextSearchableColumn" @@ PLAINTO_TSQUERY('english', $2)
);
`;
const searchResults: AssetEntity[] = await this.assetRepository.query(query, [
authUser.id,
searchAssetDto.searchTerm,
]);
return searchResults.map((asset) => mapAsset(asset));
}
async getCuratedLocation(authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> {
return this._assetRepository.getLocationsByUserId(authUser.id);
}
async getCuratedObject(authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> {
return this._assetRepository.getDetectedObjectsByUserId(authUser.id);
}
async checkDuplicatedAsset(
authUser: AuthUserDto,
checkDuplicateAssetDto: CheckDuplicateAssetDto,
): Promise<CheckDuplicateAssetResponseDto> {
const res = await this.assetRepository.findOne({
where: {
deviceAssetId: checkDuplicateAssetDto.deviceAssetId,
deviceId: checkDuplicateAssetDto.deviceId,
ownerId: authUser.id,
},
});
const isDuplicated = res ? true : false;
return new CheckDuplicateAssetResponseDto(isDuplicated, res?.id);
}
async checkExistingAssets(
authUser: AuthUserDto,
checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return {
existingIds: await this._assetRepository.getExistingAssets(authUser.id, checkExistingAssetsDto),
};
}
async bulkUploadCheck(authUser: AuthUserDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
// support base64 and hex checksums
for (const asset of dto.assets) {
if (asset.checksum.length === 28) {
asset.checksum = Buffer.from(asset.checksum, 'base64').toString('hex');
}
}
const checksums: Buffer[] = dto.assets.map((asset) => Buffer.from(asset.checksum, 'hex'));
const results = await this._assetRepository.getAssetsByChecksums(authUser.id, checksums);
const checksumMap: Record<string, string> = {};
for (const { id, checksum } of results) {
checksumMap[checksum.toString('hex')] = id;
}
return {
results: dto.assets.map(({ id, checksum }) => {
const duplicate = checksumMap[checksum];
if (duplicate) {
return {
id,
assetId: duplicate,
action: AssetUploadAction.REJECT,
reason: AssetRejectReason.DUPLICATE,
};
}
// TODO mime-check
return {
id,
action: AssetUploadAction.ACCEPT,
};
}),
};
}
async getAssetCountByTimeBucket(
authUser: AuthUserDto,
getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucketResponseDto> {
if (getAssetCountByTimeBucketDto.userId !== undefined) {
await this.checkUserAccess(authUser, getAssetCountByTimeBucketDto.userId);
}
const result = await this._assetRepository.getAssetCountByTimeBucket(
getAssetCountByTimeBucketDto.userId || authUser.id,
getAssetCountByTimeBucketDto,
);
return mapAssetCountByTimeBucket(result);
}
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getAssetCountByUserId(authUser.id);
}
getArchivedAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getArchivedAssetCountByUserId(authUser.id);
}
private async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
const sharedLinkId = authUser.sharedLinkId;
for (const assetId of assetIds) {
// Step 1: Check if asset is part of a public shared
if (sharedLinkId) {
const canAccess = await this.accessRepository.hasSharedLinkAssetAccess(sharedLinkId, assetId);
if (canAccess) {
continue;
}
} else {
// Step 2: Check if user owns asset
if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
continue;
}
// Step 3: Check if any partner owns the asset
const canAccess = await this.accessRepository.hasPartnerAssetAccess(authUser.id, assetId);
if (canAccess) {
continue;
}
// Avoid additional checks if ownership is required
if (!mustBeOwner) {
// Step 2: Check if asset is part of an album shared with me
if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
continue;
}
}
}
throw new ForbiddenException();
}
}
private async checkUserAccess(authUser: AuthUserDto, userId: string) {
// Check if userId shares assets with authUser
const canAccess = await this.accessRepository.hasPartnerAccess(authUser.id, userId);
if (!canAccess) {
throw new ForbiddenException();
}
}
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) {
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) {
return !authUser.isPublicUser || authUser.isShowExif;
}
private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {
switch (format) {
case GetAssetThumbnailFormatEnum.WEBP:
if (asset.webpPath && asset.webpPath.length > 0) {
return asset.webpPath;
}
case GetAssetThumbnailFormatEnum.JPEG:
default:
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
return asset.resizePath;
}
}
private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): ServableFile {
/**
* Serve file viewer on the web
*/
if (query.isWeb && asset.mimeType != 'image/gif') {
if (!asset.resizePath) {
this.logger.error('Error serving IMAGE asset for web');
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
}
return { filepath: asset.resizePath, contentType: 'image/jpeg' };
}
/**
* Serve thumbnail image for both web and mobile app
*/
if ((!query.isThumb && allowOriginalFile) || (query.isWeb && asset.mimeType === 'image/gif')) {
return { filepath: asset.originalPath, contentType: asset.mimeType as string };
}
if (asset.webpPath && asset.webpPath.length > 0) {
return { filepath: asset.webpPath, contentType: 'image/webp' };
}
if (!asset.resizePath) {
throw new Error('resizePath not set');
}
return { filepath: asset.resizePath, contentType: 'image/jpeg' };
}
private async streamFile(filepath: string, res: Res, headers: Record<string, string>, contentType?: string | null) {
if (contentType) {
res.header('Content-Type', contentType);
}
// etag
const { size, mtimeNs } = await fs.stat(filepath, { bigint: true });
const etag = `W/"${size}-${mtimeNs}"`;
res.setHeader('ETag', etag);
if (etag === headers['if-none-match']) {
res.status(304);
return;
}
await fs.access(filepath, constants.R_OK);
return new StreamableFile(createReadStream(filepath));
}
}

View File

@@ -0,0 +1,20 @@
import { Type } from 'class-transformer';
import { IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
export class AssetBulkUploadCheckItem {
@IsString()
@IsNotEmpty()
id!: string;
/** base64 or hex encoded sha1 hash */
@IsString()
@IsNotEmpty()
checksum!: string;
}
export class AssetBulkUploadCheckDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetBulkUploadCheckItem)
assets!: AssetBulkUploadCheckItem[];
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export class AssetSearchDto {
@IsOptional()
@IsNotEmpty()
@IsBoolean()
@Transform(toBoolean)
isFavorite?: boolean;
@IsOptional()
@IsNotEmpty()
@IsBoolean()
@Transform(toBoolean)
isArchived?: boolean;
/**
* Include assets without thumbnails
*/
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
withoutThumbs?: boolean;
@IsOptional()
@IsNumber()
skip?: number;
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
}

View File

@@ -0,0 +1,9 @@
import { IsNotEmpty } from 'class-validator';
export class CheckDuplicateAssetDto {
@IsNotEmpty()
deviceAssetId!: string;
@IsNotEmpty()
deviceId!: string;
}

View File

@@ -0,0 +1,28 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { CheckExistingAssetsDto } from './check-existing-assets.dto';
describe('CheckExistingAssetsDto', () => {
it('should fail with an empty list', () => {
const dto = plainToInstance(CheckExistingAssetsDto, { deviceAssetIds: [], deviceId: 'test-device' });
const errors = validateSync(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toEqual('deviceAssetIds');
});
it('should fail with an empty string', () => {
const dto = plainToInstance(CheckExistingAssetsDto, { deviceAssetIds: [''], deviceId: 'test-device' });
const errors = validateSync(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toEqual('deviceAssetIds');
});
it('should work with valid asset ids', () => {
const dto = plainToInstance(CheckExistingAssetsDto, {
deviceAssetIds: ['asset-1', 'asset-2'],
deviceId: 'test-device',
});
const errors = validateSync(dto);
expect(errors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,11 @@
import { ArrayNotEmpty, IsNotEmpty, IsString } from 'class-validator';
export class CheckExistingAssetsDto {
@ArrayNotEmpty()
@IsString({ each: true })
@IsNotEmpty({ each: true })
deviceAssetIds!: string[];
@IsNotEmpty()
deviceId!: string;
}

View File

@@ -0,0 +1,41 @@
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;
}

View File

@@ -0,0 +1,67 @@
import { AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
import { ImmichFile } from '../../../config/asset-upload.config';
export class CreateAssetDto {
@IsNotEmpty()
deviceAssetId!: string;
@IsNotEmpty()
deviceId!: string;
@IsNotEmpty()
@IsEnum(AssetType)
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
assetType!: AssetType;
@IsNotEmpty()
fileCreatedAt!: Date;
@IsNotEmpty()
fileModifiedAt!: Date;
@IsNotEmpty()
isFavorite!: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsBoolean()
isVisible?: boolean;
@IsNotEmpty()
fileExtension!: string;
@IsOptional()
duration?: string;
// The properties below are added to correctly generate the API docs
// and client SDKs. Validation should be handled in the controller.
@ApiProperty({ type: 'string', format: 'binary' })
assetData!: any;
@ApiProperty({ type: 'string', format: 'binary' })
livePhotoData?: any;
@ApiProperty({ type: 'string', format: 'binary' })
sidecarData?: any;
}
export interface UploadFile {
mimeType: string;
checksum: Buffer;
originalPath: string;
originalName: string;
}
export function mapToUploadFile(file: ImmichFile): UploadFile {
return {
checksum: file.checksum,
mimeType: file.mimetype,
originalPath: file.path,
originalName: file.originalname,
};
}

View File

@@ -0,0 +1,45 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateExifDto {
@IsNotEmpty()
assetId!: string;
@IsOptional()
make?: string;
@IsOptional()
model?: string;
@IsOptional()
exifImageWidth?: number;
@IsOptional()
exifImageHeight?: number;
@IsOptional()
fileSizeInByte?: number;
@IsOptional()
orientation?: string;
@IsOptional()
dateTimeOriginal?: Date;
@IsOptional()
modifiedDate?: Date;
@IsOptional()
lensModel?: string;
@IsOptional()
fNumber?: number;
@IsOptional()
focalLenght?: number;
@IsOptional()
iso?: number;
@IsOptional()
exposureTime?: number;
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class DeleteAssetDto {
@IsNotEmpty()
@ApiProperty({
isArray: true,
type: String,
title: 'Array of asset IDs to delete',
example: [
'bf973405-3f2a-48d2-a687-2ed4167164be',
'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
],
})
ids!: string[];
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsUUID } from 'class-validator';
export class DeviceIdDto {
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
deviceId!: string;
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class DownloadFilesDto {
@IsNotEmpty()
@ApiProperty({
isArray: true,
type: String,
title: 'Array of asset ids to be downloaded',
})
assetIds!: string[];
}

View File

@@ -0,0 +1,14 @@
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
export class DownloadDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsPositive()
@IsNumber()
@Type(() => Number)
skip?: number;
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export class GetAssetByTimeBucketDto {
@IsNotEmpty()
@ApiProperty({
isArray: true,
type: String,
title: 'Array of date time buckets',
example: ['2015-06-01T00:00:00.000Z', '2016-02-01T00:00:00.000Z', '2016-03-01T00:00:00.000Z'],
})
timeBucket!: string[];
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
/**
* Include assets without thumbnails
*/
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
withoutThumbs?: boolean;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export enum TimeGroupEnum {
Day = 'day',
Month = 'month',
}
export class GetAssetCountByTimeBucketDto {
@IsNotEmpty()
@ApiProperty({
type: String,
enum: TimeGroupEnum,
enumName: 'TimeGroupEnum',
})
timeGroup!: TimeGroupEnum;
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
/**
* Include assets without thumbnails
*/
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
withoutThumbs?: boolean;
}

View File

@@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional } from 'class-validator';
export enum GetAssetThumbnailFormatEnum {
JPEG = 'JPEG',
WEBP = 'WEBP',
}
export class GetAssetThumbnailDto {
@IsOptional()
@ApiProperty({
type: String,
enum: GetAssetThumbnailFormatEnum,
default: GetAssetThumbnailFormatEnum.WEBP,
required: false,
enumName: 'ThumbnailFormat',
})
format: GetAssetThumbnailFormatEnum = GetAssetThumbnailFormatEnum.WEBP;
}

View File

@@ -0,0 +1,6 @@
import { IsNotEmpty } from 'class-validator';
export class SearchAssetDto {
@IsNotEmpty()
searchTerm!: string;
}

View File

@@ -0,0 +1,12 @@
export class SearchPropertiesDto {
tags?: string[];
objects?: string[];
assetType?: string;
orientation?: string;
lensModel?: string;
make?: string;
model?: string;
city?: string;
state?: string;
country?: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export class ServeFileDto {
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
@ApiProperty({ type: Boolean, title: 'Is serve thumbnail (resize) file' })
isThumb?: boolean;
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
@ApiProperty({ type: Boolean, title: 'Is request made from web' })
isWeb?: boolean;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpdateAssetDto {
@IsOptional()
@IsBoolean()
isFavorite?: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ApiProperty({
isArray: true,
type: String,
title: 'Array of tag IDs to add to the asset',
example: [
'bf973405-3f2a-48d2-a687-2ed4167164be',
'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
],
})
tagIds?: string[];
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,20 @@
export class AssetBulkUploadCheckResult {
id!: string;
action!: AssetUploadAction;
reason?: AssetRejectReason;
assetId?: string;
}
export class AssetBulkUploadCheckResponseDto {
results!: AssetBulkUploadCheckResult[];
}
export enum AssetUploadAction {
ACCEPT = 'accept',
REJECT = 'reject',
}
export enum AssetRejectReason {
DUPLICATE = 'duplicate',
UNSUPPORTED_FORMAT = 'unsupported-format',
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
export class AssetCountByTimeBucket {
@ApiProperty({ type: 'string' })
timeBucket!: string;
@ApiProperty({ type: 'integer' })
count!: number;
}
export class AssetCountByTimeBucketResponseDto {
buckets!: AssetCountByTimeBucket[];
@ApiProperty({ type: 'integer' })
totalCount!: number;
}
export function mapAssetCountByTimeBucket(result: AssetCountByTimeBucket[]): AssetCountByTimeBucketResponseDto {
return {
buckets: result,
totalCount: result.map((group) => group.count).reduce((a, b) => a + b, 0),
};
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
export class AssetCountByUserIdResponseDto {
@ApiProperty({ type: 'integer' })
audio = 0;
@ApiProperty({ type: 'integer' })
photos = 0;
@ApiProperty({ type: 'integer' })
videos = 0;
@ApiProperty({ type: 'integer' })
other = 0;
@ApiProperty({ type: 'integer' })
total = 0;
}

View File

@@ -0,0 +1,4 @@
export class AssetFileUploadResponseDto {
id!: string;
duplicate!: boolean;
}

View File

@@ -0,0 +1,8 @@
export class CheckDuplicateAssetResponseDto {
constructor(isExist: boolean, id?: string) {
this.isExist = isExist;
this.id = id;
}
isExist: boolean;
id?: string;
}

View File

@@ -0,0 +1,3 @@
export class CheckExistingAssetsResponseDto {
existingIds!: string[];
}

View File

@@ -0,0 +1,7 @@
export class CuratedLocationsResponseDto {
id!: string;
city!: string;
resizePath!: string;
deviceAssetId!: string;
deviceId!: string;
}

View File

@@ -0,0 +1,7 @@
export class CuratedObjectsResponseDto {
id!: string;
object!: string;
resizePath!: string;
deviceAssetId!: string;
deviceId!: string;
}

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
export enum DeleteAssetStatusEnum {
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
}
export class DeleteAssetResponseDto {
id!: string;
@ApiProperty({ type: 'string', enum: DeleteAssetStatusEnum, enumName: 'DeleteAssetStatus' })
status!: DeleteAssetStatusEnum;
}

View File

@@ -0,0 +1,25 @@
import { FileValidator, Injectable } from '@nestjs/common';
@Injectable()
export default class FileNotEmptyValidator extends FileValidator {
requiredFields: string[];
constructor(requiredFields: string[]) {
super({});
this.requiredFields = requiredFields;
}
isValid(files?: any): boolean {
if (!files) {
return false;
}
return this.requiredFields.every((field) => {
return files[field];
});
}
buildErrorMessage(): string {
return `Field(s) ${this.requiredFields.join(', ')} should not be empty`;
}
}

View File

@@ -0,0 +1,11 @@
import { ParseUUIDPipe, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class ParseMeUUIDPipe extends ParseUUIDPipe {
async transform(value: string, metadata: ArgumentMetadata) {
if (value == 'me') {
return value;
}
return super.transform(value, metadata);
}
}

View File

@@ -0,0 +1,60 @@
import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra';
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { AlbumModule } from './api-v1/album/album.module';
import { AssetModule } from './api-v1/asset/asset.module';
import { AppService } from './app.service';
import {
AlbumController,
APIKeyController,
AppController,
AssetController,
AuthController,
JobController,
OAuthController,
PartnerController,
PersonController,
SearchController,
ServerInfoController,
SharedLinkController,
SystemConfigController,
TagController,
UserController,
} from './controllers';
import { AuthGuard } from './middlewares/auth.guard';
@Module({
imports: [
//
DomainModule.register({ imports: [InfraModule] }),
AssetModule,
AlbumModule,
ScheduleModule.forRoot(),
],
controllers: [
AppController,
AlbumController,
APIKeyController,
AssetController,
AuthController,
JobController,
OAuthController,
PartnerController,
SearchController,
ServerInfoController,
SharedLinkController,
SystemConfigController,
TagController,
UserController,
PersonController,
],
providers: [
//
{ provide: APP_GUARD, useExisting: AuthGuard },
AuthGuard,
AppService,
],
})
export class AppModule {}

View File

@@ -0,0 +1,27 @@
import { JobService, MACHINE_LEARNING_ENABLED, SearchService, StorageService } from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class AppService {
private logger = new Logger(AppService.name);
constructor(
private jobService: JobService,
private searchService: SearchService,
private storageService: StorageService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async onNightlyJob() {
await this.jobService.handleNightlyJobs();
}
async init() {
this.storageService.init();
await this.searchService.init();
this.logger.log(`Machine learning is ${MACHINE_LEARNING_ENABLED ? 'enabled' : 'disabled'}`);
this.logger.log(`Search is ${this.searchService.isEnabled() ? 'enabled' : 'disabled'}`);
}
}

View File

@@ -0,0 +1,101 @@
import { IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME, SERVER_VERSION } from '@app/domain';
import { INestApplication } from '@nestjs/common';
import {
DocumentBuilder,
OpenAPIObject,
SwaggerCustomOptions,
SwaggerDocumentOptions,
SwaggerModule,
} from '@nestjs/swagger';
import { Response } from 'express';
import { writeFileSync } from 'fs';
import path from 'path';
import { Metadata } from './decorators/authenticated.decorator';
import { DownloadArchive } from './modules/download/download.service';
export const handleDownload = (download: DownloadArchive, res: Response) => {
res.attachment(download.fileName);
res.setHeader('X-Immich-Content-Length-Hint', download.fileSize);
res.setHeader('X-Immich-Archive-File-Count', download.fileCount);
res.setHeader('X-Immich-Archive-Complete', `${download.complete}`);
return download.stream;
};
const patchOpenAPI = (document: OpenAPIObject) => {
for (const path of Object.values(document.paths)) {
const operations = {
get: path.get,
put: path.put,
post: path.post,
delete: path.delete,
options: path.options,
head: path.head,
patch: path.patch,
trace: path.trace,
};
for (const operation of Object.values(operations)) {
if (!operation) {
continue;
}
if ((operation.security || []).find((item) => !!item[Metadata.PUBLIC_SECURITY])) {
delete operation.security;
}
if (operation.summary === '') {
delete operation.summary;
}
if (operation.description === '') {
delete operation.description;
}
}
}
return document;
};
export const useSwagger = (app: INestApplication, isDev: boolean) => {
const config = new DocumentBuilder()
.setTitle('Immich')
.setDescription('Immich API')
.setVersion(SERVER_VERSION)
.addBearerAuth({
type: 'http',
scheme: 'Bearer',
in: 'header',
})
.addCookieAuth(IMMICH_ACCESS_COOKIE)
.addApiKey(
{
type: 'apiKey',
in: 'header',
name: IMMICH_API_KEY_HEADER,
},
IMMICH_API_KEY_NAME,
)
.addServer('/api')
.build();
const options: SwaggerDocumentOptions = {
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
};
const doc = SwaggerModule.createDocument(app, config, options);
const customOptions: SwaggerCustomOptions = {
swaggerOptions: {
persistAuthorization: true,
},
customSiteTitle: 'Immich API Documentation',
};
SwaggerModule.setup('doc', app, doc, customOptions);
if (isDev) {
// Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(doc), null, 2), { encoding: 'utf8' });
}
};

View File

@@ -0,0 +1,206 @@
import { Request } from 'express';
import * as fs from 'fs';
import { AuthRequest } from '../decorators/auth-user.decorator';
import { multerUtils } from './asset-upload.config';
const { fileFilter, destination, filename } = multerUtils;
const mock = {
req: {} as Request,
userRequest: {
user: {
id: 'test-user',
},
body: {
deviceId: 'test-device',
fileExtension: '.jpg',
},
} as AuthRequest,
file: { originalname: 'test.jpg' } as Express.Multer.File,
};
jest.mock('fs');
describe('assetUploadOption', () => {
let callback: jest.Mock;
let existsSync: jest.Mock;
let mkdirSync: jest.Mock;
beforeEach(() => {
jest.mock('fs');
mkdirSync = fs.mkdirSync as jest.Mock;
existsSync = fs.existsSync as jest.Mock;
callback = jest.fn();
existsSync.mockImplementation(() => true);
});
afterEach(() => {
jest.resetModules();
});
describe('fileFilter', () => {
it('should require a user', () => {
fileFilter(mock.req, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeDefined();
expect(name).toBeUndefined();
});
it('should allow images', async () => {
const file = { mimetype: 'image/jpeg', originalname: 'test.jpg' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow videos', async () => {
const file = { mimetype: 'video/mp4', originalname: 'test.mp4' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow webm videos', async () => {
const file = { mimetype: 'video/webm', originalname: 'test.webm' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .raf recognized', () => {
const file = { mimetype: 'image/x-fuji-raf', originalname: 'test.raf' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .srw recognized', () => {
const file = { mimetype: 'image/x-samsung-srw', originalname: 'test.srw' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .wmv videos', () => {
const file = { mimetype: 'video/x-ms-wmv', originalname: 'test.wmv' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .mkv videos', () => {
const file = { mimetype: 'video/x-matroska', originalname: 'test.mkv' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .mpg videos', () => {
const file = { mimetype: 'video/mpeg', originalname: 'test.mpg' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .flv videos', () => {
const file = { mimetype: 'video/x-flv', originalname: 'test.flv' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .mov videos with video/mov mimetype', () => {
const file = { mimetype: 'video/mov', originalname: 'test.mov' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .avi videos with video/avi mimetype', () => {
const file = { mimetype: 'video/avi', originalname: 'test.avi' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should allow .avi videos with video/x-msvideo mimetype', () => {
const file = { mimetype: 'video/x-msvideo', originalname: 'test.avi' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should not allow unknown types', async () => {
const file = { mimetype: 'application/html', originalname: 'test.html' } as any;
const callback = jest.fn();
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalled();
const [error, accepted] = callback.mock.calls[0];
expect(error).toBeDefined();
expect(accepted).toBe(false);
});
});
describe('destination', () => {
it('should require a user', () => {
destination(mock.req, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeDefined();
expect(name).toBeUndefined();
});
it('should create non-existing directories', () => {
existsSync.mockImplementation(() => false);
destination(mock.userRequest, mock.file, callback);
expect(existsSync).toHaveBeenCalled();
expect(mkdirSync).toHaveBeenCalled();
});
it('should return the destination', () => {
destination(mock.userRequest, mock.file, callback);
expect(mkdirSync).not.toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith(null, 'upload/upload/test-user');
});
});
describe('filename', () => {
it('should require a user', () => {
filename(mock.req, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeDefined();
expect(name).toBeUndefined();
});
it('should return the filename', () => {
filename(mock.userRequest, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeNull();
expect(name.endsWith('.jpg')).toBeTruthy();
});
it('should sanitize the filename', () => {
const body = { ...mock.userRequest.body, fileExtension: '.jp\u0000g' };
const request = { ...mock.userRequest, body } as Request;
filename(request, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeNull();
expect(name.endsWith(mock.userRequest.body.fileExtension)).toBeTruthy();
});
it('should not change the casing of the extension', () => {
// Case is deliberately mixed to cover both .upper() and .lower()
const body = { ...mock.userRequest.body, fileExtension: '.JpEg' };
const request = { ...mock.userRequest, body } as Request;
filename(request, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeNull();
expect(name.endsWith(body.fileExtension)).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,110 @@
import { StorageCore, StorageFolder } from '@app/domain/storage';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { createHash, randomUUID } from 'crypto';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage, StorageEngine } from 'multer';
import { extname } from 'path';
import sanitize from 'sanitize-filename';
import { AuthRequest, AuthUserDto } from '../decorators/auth-user.decorator';
import { patchFormData } from '../utils/path-form-data.util';
export interface ImmichFile extends Express.Multer.File {
/** sha1 hash of file */
checksum: Buffer;
}
export const assetUploadOption: MulterOptions = {
fileFilter,
storage: customStorage(),
};
const storageCore = new StorageCore();
export function customStorage(): StorageEngine {
const storage = diskStorage({ destination, filename });
return {
_handleFile(req, file, callback) {
const hash = createHash('sha1');
file.stream.on('data', (chunk) => hash.update(chunk));
storage._handleFile(req, file, (error, response) => {
if (error) {
hash.destroy();
callback(error);
} else {
callback(null, { ...response, checksum: hash.digest() } as ImmichFile);
}
});
},
_removeFile(req, file, callback) {
storage._removeFile(req, file, callback);
},
};
}
export const multerUtils = { fileFilter, filename, destination };
const logger = new Logger('AssetUploadConfig');
function fileFilter(req: AuthRequest, file: any, cb: any) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}
if (
file.mimetype.match(
/\/(jpg|jpeg|png|gif|avi|mov|mp4|webm|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff|3gpp|nef|x-nikon-nef|x-fuji-raf|x-samsung-srw|mpeg|x-flv|x-ms-wmv|x-matroska)$/,
)
) {
cb(null, true);
} else {
// Additionally support XML but only for sidecar files
if (file.fieldname == 'sidecarData' && file.mimetype.match(/\/xml$/)) {
return cb(null, true);
}
logger.error(`Unsupported file type ${extname(file.originalname)} file MIME type ${file.mimetype}`);
cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
}
}
function destination(req: AuthRequest, file: Express.Multer.File, cb: any) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}
const user = req.user as AuthUserDto;
const uploadFolder = storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id);
if (!existsSync(uploadFolder)) {
mkdirSync(uploadFolder, { recursive: true });
}
// Save original to disk
cb(null, uploadFolder);
}
function filename(req: AuthRequest, file: Express.Multer.File, cb: any) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}
file.originalname = patchFormData(file.originalname);
const fileNameUUID = randomUUID();
if (file.fieldname === 'livePhotoData') {
const livePhotoFileName = `${fileNameUUID}.mov`;
return cb(null, sanitize(livePhotoFileName));
}
if (file.fieldname === 'sidecarData') {
const sidecarFileName = `${fileNameUUID}.xmp`;
return cb(null, sanitize(sidecarFileName));
}
const fileName = `${fileNameUUID}${req.body['fileExtension']}`;
return cb(null, sanitize(fileName));
}

View File

@@ -0,0 +1,115 @@
import { Request } from 'express';
import * as fs from 'fs';
import { AuthRequest } from '../decorators/auth-user.decorator';
import { multerUtils } from './profile-image-upload.config';
const { fileFilter, destination, filename } = multerUtils;
const mock = {
req: {} as Request,
userRequest: {
user: {
id: 'test-user',
},
} as AuthRequest,
file: { originalname: 'test.jpg' } as Express.Multer.File,
};
jest.mock('fs');
describe('profileImageUploadOption', () => {
let callback: jest.Mock;
let existsSync: jest.Mock;
let mkdirSync: jest.Mock;
beforeEach(() => {
jest.mock('fs');
mkdirSync = fs.mkdirSync as jest.Mock;
existsSync = fs.existsSync as jest.Mock;
callback = jest.fn();
existsSync.mockImplementation(() => true);
});
afterEach(() => {
jest.resetModules();
});
describe('fileFilter', () => {
it('should require a user', () => {
fileFilter(mock.req, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeDefined();
expect(name).toBeUndefined();
});
it('should allow images', async () => {
const file = { mimetype: 'image/jpeg', originalname: 'test.jpg' } as any;
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalledWith(null, true);
});
it('should not allow gifs', async () => {
const file = { mimetype: 'image/gif', originalname: 'test.gif' } as any;
const callback = jest.fn();
fileFilter(mock.userRequest, file, callback);
expect(callback).toHaveBeenCalled();
const [error, accepted] = callback.mock.calls[0];
expect(error).toBeDefined();
expect(accepted).toBe(false);
});
});
describe('destination', () => {
it('should require a user', () => {
destination(mock.req, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeDefined();
expect(name).toBeUndefined();
});
it('should create non-existing directories', () => {
existsSync.mockImplementation(() => false);
destination(mock.userRequest, mock.file, callback);
expect(existsSync).toHaveBeenCalled();
expect(mkdirSync).toHaveBeenCalled();
});
it('should return the destination', () => {
destination(mock.userRequest, mock.file, callback);
expect(mkdirSync).not.toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith(null, 'upload/profile/test-user');
});
});
describe('filename', () => {
it('should require a user', () => {
filename(mock.req, mock.file, callback);
expect(callback).toHaveBeenCalled();
const [error, name] = callback.mock.calls[0];
expect(error).toBeDefined();
expect(name).toBeUndefined();
});
it('should return the filename', () => {
filename(mock.userRequest, mock.file, callback);
expect(mkdirSync).not.toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith(null, 'test-user.jpg');
});
it('should sanitize the filename', () => {
filename(mock.userRequest, { ...mock.file, originalname: 'test.j\u0000pg' }, callback);
expect(callback).toHaveBeenCalledWith(null, 'test-user.jpg');
});
});
});

View File

@@ -0,0 +1,61 @@
import { StorageCore, StorageFolder } from '@app/domain/storage';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import sanitize from 'sanitize-filename';
import { AuthRequest, AuthUserDto } from '../decorators/auth-user.decorator';
import { patchFormData } from '../utils/path-form-data.util';
export const profileImageUploadOption: MulterOptions = {
fileFilter,
storage: diskStorage({
destination,
filename,
}),
};
export const multerUtils = { fileFilter, filename, destination };
const storageCore = new StorageCore();
function fileFilter(req: AuthRequest, file: any, cb: any) {
if (!req.user) {
return cb(new UnauthorizedException());
}
if (file.mimetype.match(/\/(jpg|jpeg|png|heic|heif|dng|webp)$/)) {
cb(null, true);
} else {
cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
}
}
function destination(req: AuthRequest, file: Express.Multer.File, cb: any) {
if (!req.user) {
return cb(new UnauthorizedException());
}
const user = req.user as AuthUserDto;
const profileImageLocation = storageCore.getFolderLocation(StorageFolder.PROFILE, user.id);
if (!existsSync(profileImageLocation)) {
mkdirSync(profileImageLocation, { recursive: true });
}
cb(null, profileImageLocation);
}
function filename(req: AuthRequest, file: Express.Multer.File, cb: any) {
if (!req.user) {
return cb(new UnauthorizedException());
}
file.originalname = patchFormData(file.originalname);
const userId = req.user.id;
const fileName = `${userId}${extname(file.originalname)}`;
cb(null, sanitize(String(fileName)));
}

View File

@@ -0,0 +1,51 @@
import { AddUsersDto, AlbumService, AuthUserDto, CreateAlbumDto, UpdateAlbumDto } from '@app/domain';
import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Album')
@Controller('album')
@Authenticated()
@UseValidation()
export class AlbumController {
constructor(private service: AlbumService) {}
@Get()
getAllAlbums(@GetAuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto) {
return this.service.getAll(authUser, query);
}
@Post()
createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) {
return this.service.create(authUser, dto);
}
@Patch(':id')
updateAlbumInfo(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) {
return this.service.update(authUser, id, dto);
}
@Delete(':id')
deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(authUser, id);
}
@Put(':id/users')
addUsersToAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
return this.service.addUsers(authUser, id, dto);
}
@Delete(':id/user/:userId')
removeUserFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {
return this.service.removeUser(authUser, id, userId);
}
}

View File

@@ -0,0 +1,51 @@
import {
APIKeyCreateDto,
APIKeyCreateResponseDto,
APIKeyResponseDto,
APIKeyService,
APIKeyUpdateDto,
AuthUserDto,
} from '@app/domain';
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('API Key')
@Controller('api-key')
@Authenticated()
@UseValidation()
export class APIKeyController {
constructor(private service: APIKeyService) {}
@Post()
createKey(@GetAuthUser() authUser: AuthUserDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
return this.service.create(authUser, dto);
}
@Get()
getKeys(@GetAuthUser() authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
return this.service.getAll(authUser);
}
@Get(':id')
getKey(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
return this.service.getById(authUser, id);
}
@Put(':id')
updateKey(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: APIKeyUpdateDto,
): Promise<APIKeyResponseDto> {
return this.service.update(authUser, id, dto);
}
@Delete(':id')
deleteKey(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(authUser, id);
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
import { SystemConfigService } from '@app/domain';
@Controller()
export class AppController {
constructor(private configService: SystemConfigService) {}
@ApiExcludeEndpoint()
@Post('refresh-config')
@HttpCode(HttpStatus.OK)
public reloadConfig() {
return this.configService.refreshConfig();
}
}

View File

@@ -0,0 +1,20 @@
import { AssetService, AuthUserDto, MapMarkerResponseDto } from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('Asset')
@Controller('asset')
@Authenticated()
@UseValidation()
export class AssetController {
constructor(private service: AssetService) {}
@Get('/map-marker')
getMapMarkers(@GetAuthUser() authUser: AuthUserDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(authUser, options);
}
}

View File

@@ -0,0 +1,90 @@
import {
AdminSignupResponseDto,
AuthDeviceResponseDto,
AuthService,
AuthType,
AuthUserDto,
ChangePasswordDto,
IMMICH_ACCESS_COOKIE,
IMMICH_AUTH_TYPE_COOKIE,
LoginCredentialDto,
LoginDetails,
LoginResponseDto,
LogoutResponseDto,
SignUpDto,
UserResponseDto,
ValidateAccessTokenResponseDto,
} from '@app/domain';
import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common';
import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
import { Authenticated, PublicRoute } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Authentication')
@Controller('auth')
@Authenticated()
@UseValidation()
export class AuthController {
constructor(private readonly service: AuthService) {}
@PublicRoute()
@Post('login')
async login(
@Body() loginCredential: LoginCredentialDto,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.login(loginCredential, loginDetails);
res.header('Set-Cookie', cookie);
return response;
}
@PublicRoute()
@Post('admin-sign-up')
@ApiBadRequestResponse({ description: 'The server already has an admin' })
adminSignUp(@Body() signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> {
return this.service.adminSignUp(signUpCredential);
}
@Get('devices')
getAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
return this.service.getDevices(authUser);
}
@Delete('devices')
logoutAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise<void> {
return this.service.logoutDevices(authUser);
}
@Delete('devices/:id')
logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.logoutDevice(authUser, id);
}
@Post('validateToken')
validateAccessToken(): ValidateAccessTokenResponseDto {
return { authStatus: true };
}
@Post('change-password')
changePassword(@GetAuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
return this.service.changePassword(authUser, dto);
}
@Post('logout')
logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@GetAuthUser() authUser: AuthUserDto,
): Promise<LogoutResponseDto> {
const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
res.clearCookie(IMMICH_ACCESS_COOKIE);
res.clearCookie(IMMICH_AUTH_TYPE_COOKIE);
return this.service.logout(authUser, authType);
}
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsUUID } from 'class-validator';
export class UUIDParamDto {
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
id!: string;
}

View File

@@ -0,0 +1,15 @@
export * from './album.controller';
export * from './api-key.controller';
export * from './app.controller';
export * from './asset.controller';
export * from './auth.controller';
export * from './job.controller';
export * from './oauth.controller';
export * from './partner.controller';
export * from './person.controller';
export * from './search.controller';
export * from './server-info.controller';
export * from './shared-link.controller';
export * from './system-config.controller';
export * from './tag.controller';
export * from './user.controller';

View File

@@ -0,0 +1,24 @@
import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto, JobIdDto, JobService } from '@app/domain';
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('Job')
@Controller('jobs')
@Authenticated({ admin: true })
@UseValidation()
export class JobController {
constructor(private service: JobService) {}
@Get()
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.service.getAllJobsStatus();
}
@Put('/:jobId')
async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
await this.service.handleCommand(jobId, dto);
return this.service.getJobStatus(jobId);
}
}

View File

@@ -0,0 +1,62 @@
import {
AuthUserDto,
LoginDetails,
LoginResponseDto,
OAuthCallbackDto,
OAuthConfigDto,
OAuthConfigResponseDto,
OAuthService,
UserResponseDto,
} from '@app/domain';
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
import { Authenticated, PublicRoute } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('OAuth')
@Controller('oauth')
@Authenticated()
@UseValidation()
export class OAuthController {
constructor(private service: OAuthService) {}
@PublicRoute()
@Get('mobile-redirect')
@Redirect()
mobileRedirect(@Req() req: Request) {
return {
url: this.service.getMobileRedirect(req.url),
statusCode: HttpStatus.TEMPORARY_REDIRECT,
};
}
@PublicRoute()
@Post('config')
generateConfig(@Body() dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
return this.service.generateConfig(dto);
}
@PublicRoute()
@Post('callback')
async callback(
@Res({ passthrough: true }) res: Response,
@Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.login(dto, loginDetails);
res.header('Set-Cookie', cookie);
return response;
}
@Post('link')
link(@GetAuthUser() authUser: AuthUserDto, @Body() dto: OAuthCallbackDto): Promise<UserResponseDto> {
return this.service.link(authUser, dto);
}
@Post('unlink')
unlink(@GetAuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
return this.service.unlink(authUser);
}
}

View File

@@ -0,0 +1,34 @@
import { PartnerDirection, PartnerService, UserResponseDto } from '@app/domain';
import { Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Partner')
@Controller('partner')
@Authenticated()
@UseValidation()
export class PartnerController {
constructor(private service: PartnerService) {}
@Get()
@ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true })
getPartners(
@GetAuthUser() authUser: AuthUserDto,
@Query('direction') direction: PartnerDirection,
): Promise<UserResponseDto[]> {
return this.service.getAll(authUser, direction);
}
@Post(':id')
createPartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.create(authUser, id);
}
@Delete(':id')
removePartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(authUser, id);
}
}

View File

@@ -0,0 +1,57 @@
import {
AssetResponseDto,
AuthUserDto,
ImmichReadStream,
PersonResponseDto,
PersonService,
PersonUpdateDto,
} from '@app/domain';
import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
function asStreamableFile({ stream, type, length }: ImmichReadStream) {
return new StreamableFile(stream, { type, length });
}
@ApiTags('Person')
@Controller('person')
@Authenticated()
@UseValidation()
export class PersonController {
constructor(private service: PersonService) {}
@Get()
getAllPeople(@GetAuthUser() authUser: AuthUserDto): Promise<PersonResponseDto[]> {
return this.service.getAll(authUser);
}
@Get(':id')
getPerson(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
return this.service.getById(authUser, id);
}
@Put(':id')
updatePerson(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: PersonUpdateDto,
): Promise<PersonResponseDto> {
return this.service.update(authUser, id, dto);
}
@Get(':id/thumbnail')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
getPersonThumbnail(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.getThumbnail(authUser, id).then(asStreamableFile);
}
@Get(':id/assets')
getPersonAssets(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
return this.service.getAssets(authUser, id);
}
}

View File

@@ -0,0 +1,36 @@
import {
AuthUserDto,
SearchConfigResponseDto,
SearchDto,
SearchExploreResponseDto,
SearchResponseDto,
SearchService,
} from '@app/domain';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('Search')
@Controller('search')
@Authenticated()
@UseValidation()
export class SearchController {
constructor(private service: SearchService) {}
@Get()
search(@GetAuthUser() authUser: AuthUserDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
return this.service.search(authUser, dto);
}
@Get('config')
getSearchConfig(): SearchConfigResponseDto {
return this.service.getConfig();
}
@Get('explore')
getExploreData(@GetAuthUser() authUser: AuthUserDto): Promise<SearchExploreResponseDto[]> {
return this.service.getExploreData(authUser) as Promise<SearchExploreResponseDto[]>;
}
}

View File

@@ -0,0 +1,42 @@
import {
ServerInfoResponseDto,
ServerInfoService,
ServerPingResponse,
ServerStatsResponseDto,
ServerVersionReponseDto,
} from '@app/domain';
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AdminRoute, Authenticated, PublicRoute } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('Server Info')
@Controller('server-info')
@Authenticated()
@UseValidation()
export class ServerInfoController {
constructor(private service: ServerInfoService) {}
@Get()
getServerInfo(): Promise<ServerInfoResponseDto> {
return this.service.getInfo();
}
@PublicRoute()
@Get('/ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@PublicRoute()
@Get('/version')
getServerVersion(): ServerVersionReponseDto {
return this.service.getVersion();
}
@AdminRoute()
@Get('/stats')
getStats(): Promise<ServerStatsResponseDto> {
return this.service.getStats();
}
}

View File

@@ -0,0 +1,48 @@
import { AuthUserDto, EditSharedLinkDto, SharedLinkResponseDto, SharedLinkService } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } 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')
@Authenticated()
@UseValidation()
export class SharedLinkController {
constructor(private readonly service: SharedLinkService) {}
@Get()
getAllSharedLinks(@GetAuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
return this.service.getAll(authUser);
}
@SharedLinkRoute()
@Get('me')
getMySharedLink(@GetAuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
return this.service.getMine(authUser);
}
@Get(':id')
getSharedLinkById(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
): Promise<SharedLinkResponseDto> {
return this.service.get(authUser, id);
}
@Patch(':id')
updateSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: EditSharedLinkDto,
): Promise<SharedLinkResponseDto> {
return this.service.update(authUser, id, dto);
}
@Delete(':id')
removeSharedLink(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(authUser, id);
}
}

View File

@@ -0,0 +1,33 @@
import { SystemConfigDto, SystemConfigService, SystemConfigTemplateStorageOptionDto } from '@app/domain';
import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('System Config')
@Controller('system-config')
@Authenticated({ admin: true })
@UseValidation()
export class SystemConfigController {
constructor(private readonly service: SystemConfigService) {}
@Get()
getConfig(): Promise<SystemConfigDto> {
return this.service.getConfig();
}
@Get('defaults')
getDefaults(): SystemConfigDto {
return this.service.getDefaults();
}
@Put()
updateConfig(@Body() dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.service.updateConfig(dto);
}
@Get('storage-template-options')
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
return this.service.getStorageTemplateOptions();
}
}

View File

@@ -0,0 +1,75 @@
import {
AssetIdsDto,
AssetIdsResponseDto,
AssetResponseDto,
CreateTagDto,
TagResponseDto,
TagService,
UpdateTagDto,
} from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Tag')
@Controller('tag')
@Authenticated()
@UseValidation()
export class TagController {
constructor(private service: TagService) {}
@Post()
createTag(@GetAuthUser() authUser: AuthUserDto, @Body() dto: CreateTagDto): Promise<TagResponseDto> {
return this.service.create(authUser, dto);
}
@Get()
getAllTags(@GetAuthUser() authUser: AuthUserDto): Promise<TagResponseDto[]> {
return this.service.getAll(authUser);
}
@Get(':id')
getTagById(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
return this.service.getById(authUser, id);
}
@Patch(':id')
updateTag(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateTagDto,
): Promise<TagResponseDto> {
return this.service.update(authUser, id, dto);
}
@Delete(':id')
deleteTag(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(authUser, id);
}
@Get(':id/assets')
getTagAssets(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
return this.service.getAssets(authUser, id);
}
@Put(':id/assets')
tagAssets(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetIdsDto,
): Promise<AssetIdsResponseDto[]> {
return this.service.addAssets(authUser, id, dto);
}
@Delete(':id/assets')
untagAssets(
@GetAuthUser() authUser: AuthUserDto,
@Body() dto: AssetIdsDto,
@Param() { id }: UUIDParamDto,
): Promise<AssetIdsResponseDto[]> {
return this.service.removeAssets(authUser, id, dto);
}
}

View File

@@ -0,0 +1,105 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Put,
Query,
UseInterceptors,
UploadedFile,
Response,
StreamableFile,
Header,
} from '@nestjs/common';
import { UserService } from '@app/domain';
import { AdminRoute, Authenticated, PublicRoute } from '../decorators/authenticated.decorator';
import { AuthUserDto, GetAuthUser } from '../decorators/auth-user.decorator';
import { CreateUserDto } from '@app/domain';
import { UpdateUserDto } from '@app/domain';
import { FileInterceptor } from '@nestjs/platform-express';
import { profileImageUploadOption } from '../config/profile-image-upload.config';
import { Response as Res } from 'express';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { UserResponseDto } from '@app/domain';
import { UserCountResponseDto } from '@app/domain';
import { CreateProfileImageDto } from '@app/domain';
import { CreateProfileImageResponseDto } from '@app/domain';
import { UserCountDto } from '@app/domain';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UserIdDto } from '@app/domain/user/dto/user-id.dto';
@ApiTags('User')
@Controller('user')
@Authenticated()
@UseValidation()
export class UserController {
constructor(private service: UserService) {}
@Get()
getAllUsers(@GetAuthUser() authUser: AuthUserDto, @Query('isAll') isAll: boolean): Promise<UserResponseDto[]> {
return this.service.getAllUsers(authUser, isAll);
}
@Get('/info/:userId')
getUserById(@Param() { userId }: UserIdDto): Promise<UserResponseDto> {
return this.service.getUserById(userId);
}
@Get('me')
getMyUserInfo(@GetAuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
return this.service.getUserInfo(authUser);
}
@AdminRoute()
@Post()
createUser(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.service.createUser(createUserDto);
}
@PublicRoute()
@Get('/count')
getUserCount(@Query() dto: UserCountDto): Promise<UserCountResponseDto> {
return this.service.getUserCount(dto);
}
@AdminRoute()
@Delete('/:userId')
deleteUser(@GetAuthUser() authUser: AuthUserDto, @Param() { userId }: UserIdDto): Promise<UserResponseDto> {
return this.service.deleteUser(authUser, userId);
}
@AdminRoute()
@Post('/:userId/restore')
restoreUser(@GetAuthUser() authUser: AuthUserDto, @Param() { userId }: UserIdDto): Promise<UserResponseDto> {
return this.service.restoreUser(authUser, userId);
}
@Put()
updateUser(@GetAuthUser() authUser: AuthUserDto, @Body() updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
return this.service.updateUser(authUser, updateUserDto);
}
@UseInterceptors(FileInterceptor('file', profileImageUploadOption))
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'A new avatar for the user',
type: CreateProfileImageDto,
})
@Post('/profile-image')
createProfileImage(
@GetAuthUser() authUser: AuthUserDto,
@UploadedFile() fileInfo: Express.Multer.File,
): Promise<CreateProfileImageResponseDto> {
return this.service.createProfileImage(authUser, fileInfo);
}
@Get('/profile-image/:userId')
@Header('Cache-Control', 'max-age=600')
async getProfileImage(@Param() { userId }: UserIdDto, @Response({ passthrough: true }) res: Res): Promise<any> {
const readableStream = await this.service.getUserProfileImage(userId);
res.header('Content-Type', 'image/jpeg');
return new StreamableFile(readableStream);
}
}

View File

@@ -0,0 +1,25 @@
export { AuthUserDto } from '@app/domain';
import { AuthUserDto, LoginDetails } from '@app/domain';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { UAParser } from 'ua-parser-js';
export interface AuthRequest extends Request {
user?: AuthUserDto;
}
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
});
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
const req = ctx.switchToHttp().getRequest();
const userAgent = UAParser(req.headers['user-agent']);
return {
clientIp: req.clientIp,
isSecure: req.secure,
deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
deviceOS: userAgent.os.name || req.headers.devicetype || '',
};
});

View File

@@ -0,0 +1,46 @@
import { IMMICH_API_KEY_NAME } from '@app/domain';
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiCookieAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger';
interface AuthenticatedOptions {
admin?: boolean;
isShared?: boolean;
}
export enum Metadata {
AUTH_ROUTE = 'auth_route',
ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route',
PUBLIC_SECURITY = 'public_security',
}
const adminDecorator = SetMetadata(Metadata.ADMIN_ROUTE, true);
const sharedLinkDecorators = [
SetMetadata(Metadata.SHARED_ROUTE, true),
ApiQuery({ name: 'key', type: String, required: false }),
];
export const Authenticated = (options: AuthenticatedOptions = {}) => {
const decorators: MethodDecorator[] = [
ApiBearerAuth(),
ApiCookieAuth(),
ApiSecurity(IMMICH_API_KEY_NAME),
SetMetadata(Metadata.AUTH_ROUTE, true),
];
if (options.admin) {
decorators.push(adminDecorator);
}
if (options.isShared) {
decorators.push(...sharedLinkDecorators);
}
return applyDecorators(...decorators);
};
export const PublicRoute = () =>
applyDecorators(SetMetadata(Metadata.AUTH_ROUTE, false), ApiSecurity(Metadata.PUBLIC_SECURITY));
export const SharedLinkRoute = () => applyDecorators(...sharedLinkDecorators);
export const AdminRoute = () => adminDecorator;

View File

@@ -0,0 +1,12 @@
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
export function UseValidation() {
return applyDecorators(
UsePipes(
new ValidationPipe({
transform: true,
whitelist: true,
}),
),
);
}

View File

@@ -0,0 +1,17 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export type Options = {
optional?: boolean;
each?: boolean;
};
export function ValidateUUID({ optional, each }: Options = { optional: false, each: false }) {
return applyDecorators(
IsUUID('4', { each }),
ApiProperty({ format: 'uuid' }),
optional ? IsOptional() : IsNotEmpty(),
each ? IsArray() : IsString(),
);
}

34
server/src/immich/main.ts Normal file
View File

@@ -0,0 +1,34 @@
import { getLogLevels, SERVER_VERSION } from '@app/domain';
import { RedisIoAdapter } from '@app/infra';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { AppService } from './app.service';
import { useSwagger } from './app.utils';
const logger = new Logger('ImmichServer');
const envName = (process.env.NODE_ENV || 'development').toUpperCase();
const port = Number(process.env.SERVER_PORT) || 3001;
const isDev = process.env.NODE_ENV === 'development';
export async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger: getLogLevels() });
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (isDev) {
app.enableCors();
}
app.useWebSocketAdapter(new RedisIoAdapter(app));
useSwagger(app, isDev);
await app.get(AppService).init();
await app.listen(port);
logger.log(`Immich Server is listening on ${port} [v${SERVER_VERSION}] [${envName}] `);
}

View File

@@ -0,0 +1,46 @@
import { AuthService } from '@app/domain';
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthRequest } from '../decorators/auth-user.decorator';
import { Metadata } from '../decorators/authenticated.decorator';
@Injectable()
export class AuthGuard implements CanActivate {
private logger = new Logger(AuthGuard.name);
constructor(private reflector: Reflector, private authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()];
const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets);
const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets);
const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets);
if (!isAuthRoute) {
return true;
}
const req = context.switchToHttp().getRequest<AuthRequest>();
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
if (!authDto) {
this.logger.warn(`Denied access to authenticated route: ${req.path}`);
return false;
}
if (authDto.isPublicUser && !isSharedRoute) {
this.logger.warn(`Denied access to non-shared route: ${req.path}`);
return false;
}
if (isAdminRoute && !authDto.isAdmin) {
this.logger.warn(`Denied access to admin only route: ${req.path}`);
return false;
}
req.user = authDto;
return true;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { DownloadService } from './download.service';
@Module({
providers: [DownloadService],
exports: [DownloadService],
})
export class DownloadModule {}

View File

@@ -0,0 +1,63 @@
import { AssetEntity } from '@app/infra/entities';
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import archiver from 'archiver';
import { extname } from 'path';
import { asHumanReadable, HumanReadableSize } from '@app/domain';
export interface DownloadArchive {
stream: StreamableFile;
fileName: string;
fileSize: number;
fileCount: number;
complete: boolean;
}
@Injectable()
export class DownloadService {
private readonly logger = new Logger(DownloadService.name);
public async downloadArchive(name: string, assets: AssetEntity[]): Promise<DownloadArchive> {
if (!assets || assets.length === 0) {
throw new BadRequestException('No assets to download.');
}
try {
const archive = archiver('zip', { store: true });
const stream = new StreamableFile(archive);
let totalSize = 0;
let fileCount = 0;
let complete = true;
for (const { originalPath, exifInfo, originalFileName } of assets) {
const name = `${originalFileName}${extname(originalPath)}`;
archive.file(originalPath, { name });
totalSize += Number(exifInfo?.fileSizeInByte || 0);
fileCount++;
// for easier testing, can be changed before merging.
if (totalSize > HumanReadableSize.GiB * 20) {
complete = false;
this.logger.log(
`Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable(
totalSize,
)})`,
);
break;
}
}
archive.finalize();
return {
stream,
fileName: `${name}.zip`,
fileSize: totalSize,
fileCount,
complete,
};
} catch (error) {
this.logger.error(`Error creating download archive ${error}`);
throw new InternalServerErrorException(`Failed to download ${name}: ${error}`, 'DownloadArchive');
}
}
}

View File

@@ -0,0 +1,3 @@
export function patchFormData(latin1: string) {
return Buffer.from(latin1, 'latin1').toString('utf8');
}

View File

@@ -0,0 +1,18 @@
import sanitize from 'sanitize-filename';
interface IValue {
value?: string;
}
export const toBoolean = ({ value }: IValue) => {
if (value == 'true') {
return true;
} else if (value == 'false') {
return false;
}
return value;
};
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, ''));