mirror of
https://github.com/KevinMidboe/immich.git
synced 2026-05-08 14:35:41 +00:00
refactor(server)*: tsconfigs (#2689)
* refactor(server): tsconfigs * chore: dummy commit * fix: start.sh * chore: restore original entry scripts
This commit is contained in:
164
server/src/immich/api-v1/album/album-repository.ts
Normal file
164
server/src/immich/api-v1/album/album-repository.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
73
server/src/immich/api-v1/album/album.controller.ts
Normal file
73
server/src/immich/api-v1/album/album.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
server/src/immich/api-v1/album/album.module.ts
Normal file
20
server/src/immich/api-v1/album/album.module.ts
Normal 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 {}
|
||||
278
server/src/immich/api-v1/album/album.service.spec.ts
Normal file
278
server/src/immich/api-v1/album/album.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
122
server/src/immich/api-v1/album/album.service.ts
Normal file
122
server/src/immich/api-v1/album/album.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
server/src/immich/api-v1/album/dto/add-assets.dto.ts
Normal file
6
server/src/immich/api-v1/album/dto/add-assets.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
|
||||
|
||||
export class AddAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
||||
6
server/src/immich/api-v1/album/dto/add-users.dto.ts
Normal file
6
server/src/immich/api-v1/album/dto/add-users.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
|
||||
|
||||
export class AddUsersDto {
|
||||
@ValidateUUID({ each: true })
|
||||
sharedUserIds!: string[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
6
server/src/immich/api-v1/album/dto/remove-assets.dto.ts
Normal file
6
server/src/immich/api-v1/album/dto/remove-assets.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
|
||||
|
||||
export class RemoveAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
387
server/src/immich/api-v1/asset/asset-repository.ts
Normal file
387
server/src/immich/api-v1/asset/asset-repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
345
server/src/immich/api-v1/asset/asset.controller.ts
Normal file
345
server/src/immich/api-v1/asset/asset.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
server/src/immich/api-v1/asset/asset.core.ts
Normal file
53
server/src/immich/api-v1/asset/asset.core.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
server/src/immich/api-v1/asset/asset.module.ts
Normal file
26
server/src/immich/api-v1/asset/asset.module.ts
Normal 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 {}
|
||||
544
server/src/immich/api-v1/asset/asset.service.spec.ts
Normal file
544
server/src/immich/api-v1/asset/asset.service.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
736
server/src/immich/api-v1/asset/asset.service.ts
Normal file
736
server/src/immich/api-v1/asset/asset.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
20
server/src/immich/api-v1/asset/dto/asset-check.dto.ts
Normal file
20
server/src/immich/api-v1/asset/dto/asset-check.dto.ts
Normal 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[];
|
||||
}
|
||||
35
server/src/immich/api-v1/asset/dto/asset-search.dto.ts
Normal file
35
server/src/immich/api-v1/asset/dto/asset-search.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CheckDuplicateAssetDto {
|
||||
@IsNotEmpty()
|
||||
deviceAssetId!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
deviceId!: string;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
67
server/src/immich/api-v1/asset/dto/create-asset.dto.ts
Normal file
67
server/src/immich/api-v1/asset/dto/create-asset.dto.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
server/src/immich/api-v1/asset/dto/create-exif.dto.ts
Normal file
45
server/src/immich/api-v1/asset/dto/create-exif.dto.ts
Normal 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;
|
||||
}
|
||||
17
server/src/immich/api-v1/asset/dto/delete-asset.dto.ts
Normal file
17
server/src/immich/api-v1/asset/dto/delete-asset.dto.ts
Normal 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[];
|
||||
}
|
||||
9
server/src/immich/api-v1/asset/dto/device-id.dto.ts
Normal file
9
server/src/immich/api-v1/asset/dto/device-id.dto.ts
Normal 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;
|
||||
}
|
||||
12
server/src/immich/api-v1/asset/dto/download-files.dto.ts
Normal file
12
server/src/immich/api-v1/asset/dto/download-files.dto.ts
Normal 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[];
|
||||
}
|
||||
14
server/src/immich/api-v1/asset/dto/download-library.dto.ts
Normal file
14
server/src/immich/api-v1/asset/dto/download-library.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
6
server/src/immich/api-v1/asset/dto/search-asset.dto.ts
Normal file
6
server/src/immich/api-v1/asset/dto/search-asset.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class SearchAssetDto {
|
||||
@IsNotEmpty()
|
||||
searchTerm!: string;
|
||||
}
|
||||
12
server/src/immich/api-v1/asset/dto/search-properties.dto.ts
Normal file
12
server/src/immich/api-v1/asset/dto/search-properties.dto.ts
Normal 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;
|
||||
}
|
||||
18
server/src/immich/api-v1/asset/dto/serve-file.dto.ts
Normal file
18
server/src/immich/api-v1/asset/dto/serve-file.dto.ts
Normal 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;
|
||||
}
|
||||
32
server/src/immich/api-v1/asset/dto/update-asset.dto.ts
Normal file
32
server/src/immich/api-v1/asset/dto/update-asset.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class AssetFileUploadResponseDto {
|
||||
id!: string;
|
||||
duplicate!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class CheckDuplicateAssetResponseDto {
|
||||
constructor(isExist: boolean, id?: string) {
|
||||
this.isExist = isExist;
|
||||
this.id = id;
|
||||
}
|
||||
isExist: boolean;
|
||||
id?: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class CheckExistingAssetsResponseDto {
|
||||
existingIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class CuratedLocationsResponseDto {
|
||||
id!: string;
|
||||
city!: string;
|
||||
resizePath!: string;
|
||||
deviceAssetId!: string;
|
||||
deviceId!: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class CuratedObjectsResponseDto {
|
||||
id!: string;
|
||||
object!: string;
|
||||
resizePath!: string;
|
||||
deviceAssetId!: string;
|
||||
deviceId!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
11
server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts
Normal file
11
server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
server/src/immich/app.module.ts
Normal file
60
server/src/immich/app.module.ts
Normal 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 {}
|
||||
27
server/src/immich/app.service.ts
Normal file
27
server/src/immich/app.service.ts
Normal 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'}`);
|
||||
}
|
||||
}
|
||||
101
server/src/immich/app.utils.ts
Normal file
101
server/src/immich/app.utils.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
206
server/src/immich/config/asset-upload.config.spec.ts
Normal file
206
server/src/immich/config/asset-upload.config.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
110
server/src/immich/config/asset-upload.config.ts
Normal file
110
server/src/immich/config/asset-upload.config.ts
Normal 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));
|
||||
}
|
||||
115
server/src/immich/config/profile-image-upload.config.spec.ts
Normal file
115
server/src/immich/config/profile-image-upload.config.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
61
server/src/immich/config/profile-image-upload.config.ts
Normal file
61
server/src/immich/config/profile-image-upload.config.ts
Normal 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)));
|
||||
}
|
||||
51
server/src/immich/controllers/album.controller.ts
Normal file
51
server/src/immich/controllers/album.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
server/src/immich/controllers/api-key.controller.ts
Normal file
51
server/src/immich/controllers/api-key.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
server/src/immich/controllers/app.controller.ts
Normal file
15
server/src/immich/controllers/app.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
20
server/src/immich/controllers/asset.controller.ts
Normal file
20
server/src/immich/controllers/asset.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
90
server/src/immich/controllers/auth.controller.ts
Normal file
90
server/src/immich/controllers/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
server/src/immich/controllers/dto/uuid-param.dto.ts
Normal file
9
server/src/immich/controllers/dto/uuid-param.dto.ts
Normal 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;
|
||||
}
|
||||
15
server/src/immich/controllers/index.ts
Normal file
15
server/src/immich/controllers/index.ts
Normal 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';
|
||||
24
server/src/immich/controllers/job.controller.ts
Normal file
24
server/src/immich/controllers/job.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
62
server/src/immich/controllers/oauth.controller.ts
Normal file
62
server/src/immich/controllers/oauth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
server/src/immich/controllers/partner.controller.ts
Normal file
34
server/src/immich/controllers/partner.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
57
server/src/immich/controllers/person.controller.ts
Normal file
57
server/src/immich/controllers/person.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
server/src/immich/controllers/search.controller.ts
Normal file
36
server/src/immich/controllers/search.controller.ts
Normal 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[]>;
|
||||
}
|
||||
}
|
||||
42
server/src/immich/controllers/server-info.controller.ts
Normal file
42
server/src/immich/controllers/server-info.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
48
server/src/immich/controllers/shared-link.controller.ts
Normal file
48
server/src/immich/controllers/shared-link.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
server/src/immich/controllers/system-config.controller.ts
Normal file
33
server/src/immich/controllers/system-config.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
75
server/src/immich/controllers/tag.controller.ts
Normal file
75
server/src/immich/controllers/tag.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
105
server/src/immich/controllers/user.controller.ts
Normal file
105
server/src/immich/controllers/user.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
server/src/immich/decorators/auth-user.decorator.ts
Normal file
25
server/src/immich/decorators/auth-user.decorator.ts
Normal 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 || '',
|
||||
};
|
||||
});
|
||||
46
server/src/immich/decorators/authenticated.decorator.ts
Normal file
46
server/src/immich/decorators/authenticated.decorator.ts
Normal 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;
|
||||
12
server/src/immich/decorators/use-validation.decorator.ts
Normal file
12
server/src/immich/decorators/use-validation.decorator.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
|
||||
export function UseValidation() {
|
||||
return applyDecorators(
|
||||
UsePipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
17
server/src/immich/decorators/validate-uuid.decorator.ts
Normal file
17
server/src/immich/decorators/validate-uuid.decorator.ts
Normal 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
34
server/src/immich/main.ts
Normal 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}] `);
|
||||
}
|
||||
46
server/src/immich/middlewares/auth.guard.ts
Normal file
46
server/src/immich/middlewares/auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
server/src/immich/modules/download/download.module.ts
Normal file
8
server/src/immich/modules/download/download.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DownloadService } from './download.service';
|
||||
|
||||
@Module({
|
||||
providers: [DownloadService],
|
||||
exports: [DownloadService],
|
||||
})
|
||||
export class DownloadModule {}
|
||||
63
server/src/immich/modules/download/download.service.ts
Normal file
63
server/src/immich/modules/download/download.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
3
server/src/immich/utils/path-form-data.util.ts
Normal file
3
server/src/immich/utils/path-form-data.util.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function patchFormData(latin1: string) {
|
||||
return Buffer.from(latin1, 'latin1').toString('utf8');
|
||||
}
|
||||
18
server/src/immich/utils/transform.util.ts
Normal file
18
server/src/immich/utils/transform.util.ts
Normal 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, ''));
|
||||
Reference in New Issue
Block a user