mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +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:
18
server/src/cli/app.module.ts
Normal file
18
server/src/cli/app.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DomainModule } from '@app/domain';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ListUsersCommand } from './commands/list-users.command';
|
||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from './commands/password-login';
|
||||
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/reset-admin-password.command';
|
||||
|
||||
@Module({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] })],
|
||||
providers: [
|
||||
ResetAdminPasswordCommand,
|
||||
PromptPasswordQuestions,
|
||||
EnablePasswordLoginCommand,
|
||||
DisablePasswordLoginCommand,
|
||||
ListUsersCommand,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
23
server/src/cli/commands/list-users.command.ts
Normal file
23
server/src/cli/commands/list-users.command.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { UserService } from '@app/domain';
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { CLI_USER } from '../constants';
|
||||
|
||||
@Command({
|
||||
name: 'list-users',
|
||||
description: 'List Immich users',
|
||||
})
|
||||
export class ListUsersCommand extends CommandRunner {
|
||||
constructor(private userService: UserService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const users = await this.userService.getAllUsers(CLI_USER, true);
|
||||
console.dir(users);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to load users');
|
||||
}
|
||||
}
|
||||
}
|
||||
39
server/src/cli/commands/password-login.ts
Normal file
39
server/src/cli/commands/password-login.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { SystemConfigService } from '@app/domain';
|
||||
import axios from 'axios';
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
@Command({
|
||||
name: 'enable-password-login',
|
||||
description: 'Enable password login',
|
||||
})
|
||||
export class EnablePasswordLoginCommand extends CommandRunner {
|
||||
constructor(private configService: SystemConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const config = await this.configService.getConfig();
|
||||
config.passwordLogin.enabled = true;
|
||||
await this.configService.updateConfig(config);
|
||||
await axios.post('http://localhost:3001/refresh-config');
|
||||
console.log('Password login has been enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'disable-password-login',
|
||||
description: 'Disable password login',
|
||||
})
|
||||
export class DisablePasswordLoginCommand extends CommandRunner {
|
||||
constructor(private configService: SystemConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const config = await this.configService.getConfig();
|
||||
config.passwordLogin.enabled = false;
|
||||
await this.configService.updateConfig(config);
|
||||
await axios.post('http://localhost:3001/refresh-config');
|
||||
console.log('Password login has been disabled.');
|
||||
}
|
||||
}
|
||||
49
server/src/cli/commands/reset-admin-password.command.ts
Normal file
49
server/src/cli/commands/reset-admin-password.command.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { UserResponseDto, UserService } from '@app/domain';
|
||||
import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
|
||||
|
||||
@Command({
|
||||
name: 'reset-admin-password',
|
||||
description: 'Reset the admin password',
|
||||
})
|
||||
export class ResetAdminPasswordCommand extends CommandRunner {
|
||||
constructor(private userService: UserService, private readonly inquirer: InquirerService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const ask = (admin: UserResponseDto) => {
|
||||
const { id, oauthId, email, firstName, lastName } = admin;
|
||||
console.log(`Found Admin:
|
||||
- ID=${id}
|
||||
- OAuth ID=${oauthId}
|
||||
- Email=${email}
|
||||
- Name=${firstName} ${lastName}`);
|
||||
|
||||
return this.inquirer.ask<{ password: string }>('prompt-password', undefined).then(({ password }) => password);
|
||||
};
|
||||
|
||||
try {
|
||||
const { password, provided } = await this.userService.resetAdminPassword(ask);
|
||||
|
||||
if (provided) {
|
||||
console.log(`The admin password has been updated.`);
|
||||
} else {
|
||||
console.log(`The admin password has been updated to:\n${password}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to reset admin password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'prompt-password' })
|
||||
export class PromptPasswordQuestions {
|
||||
@Question({
|
||||
message: 'Please choose a new password (optional)',
|
||||
name: 'password',
|
||||
})
|
||||
parsePassword(val: string) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
9
server/src/cli/constants.ts
Normal file
9
server/src/cli/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AuthUserDto } from '@app/domain';
|
||||
|
||||
export const CLI_USER: AuthUserDto = {
|
||||
id: 'cli',
|
||||
email: 'cli@immich.app',
|
||||
isAdmin: true,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
6
server/src/cli/immich.ts
Executable file
6
server/src/cli/immich.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import { CommandFactory } from 'nest-commander';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
export async function bootstrap() {
|
||||
await CommandFactory.run(AppModule, ['warn', 'error']);
|
||||
}
|
||||
7
server/src/domain/access/access.repository.ts
Normal file
7
server/src/domain/access/access.repository.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const IAccessRepository = 'IAccessRepository';
|
||||
|
||||
export interface IAccessRepository {
|
||||
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
|
||||
hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
hasSharedLinkAssetAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
}
|
||||
1
server/src/domain/access/index.ts
Normal file
1
server/src/domain/access/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './access.repository';
|
||||
24
server/src/domain/album/album.repository.ts
Normal file
24
server/src/domain/album/album.repository.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AlbumEntity } from '@app/infra/entities';
|
||||
|
||||
export const IAlbumRepository = 'IAlbumRepository';
|
||||
|
||||
export interface AlbumAssetCount {
|
||||
albumId: string;
|
||||
assetCount: number;
|
||||
}
|
||||
|
||||
export interface IAlbumRepository {
|
||||
getByIds(ids: string[]): Promise<AlbumEntity[]>;
|
||||
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
|
||||
hasAsset(id: string, assetId: string): Promise<boolean>;
|
||||
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
|
||||
getInvalidThumbnail(): Promise<string[]>;
|
||||
getOwned(ownerId: string): Promise<AlbumEntity[]>;
|
||||
getShared(ownerId: string): Promise<AlbumEntity[]>;
|
||||
getNotShared(ownerId: string): Promise<AlbumEntity[]>;
|
||||
deleteAll(userId: string): Promise<void>;
|
||||
getAll(): Promise<AlbumEntity[]>;
|
||||
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||
delete(album: AlbumEntity): Promise<void>;
|
||||
}
|
||||
393
server/src/domain/album/album.service.spec.ts
Normal file
393
server/src/domain/album/album.service.spec.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
albumStub,
|
||||
authStub,
|
||||
newAlbumRepositoryMock,
|
||||
newAssetRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
userEntityStub,
|
||||
} from '@test';
|
||||
import { IAssetRepository } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IAlbumRepository } from './album.repository';
|
||||
import { AlbumService } from './album.service';
|
||||
|
||||
describe(AlbumService.name, () => {
|
||||
let sut: AlbumService;
|
||||
let albumMock: jest.Mocked<IAlbumRepository>;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
albumMock = newAlbumRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new AlbumService(albumMock, assetMock, jobMock, userMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('gets list of albums for auth user', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.empty.id, assetCount: 0 },
|
||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0 },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toEqual(albumStub.empty.id);
|
||||
expect(result[1].id).toEqual(albumStub.sharedWithUser.id);
|
||||
});
|
||||
|
||||
it('gets list of albums that have a specific asset', async () => {
|
||||
albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(albumStub.oneAsset.id);
|
||||
expect(albumMock.getByAssetId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gets list of albums that are shared', async () => {
|
||||
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { shared: true });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(albumStub.sharedWithUser.id);
|
||||
expect(albumMock.getShared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gets list of albums that are NOT shared', async () => {
|
||||
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { shared: false });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(albumStub.empty.id);
|
||||
expect(albumMock.getNotShared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('counts assets correctly', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].assetCount).toEqual(1);
|
||||
expect(albumMock.getOwned).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the album thumbnail by listing all albums', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
|
||||
assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes the thumbnail for an empty album', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
|
||||
albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
||||
assetMock.getFirstAssetForAlbumId.mockResolvedValue(null);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates album', async () => {
|
||||
albumMock.create.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({
|
||||
albumName: 'Empty album',
|
||||
albumThumbnailAssetId: null,
|
||||
assetCount: 0,
|
||||
assets: [],
|
||||
createdAt: expect.anything(),
|
||||
id: 'album-1',
|
||||
owner: {
|
||||
email: 'admin@test.com',
|
||||
firstName: 'admin_first_name',
|
||||
id: 'admin_id',
|
||||
isAdmin: true,
|
||||
lastName: 'admin_last_name',
|
||||
oauthId: '',
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: false,
|
||||
storageLabel: 'admin',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
},
|
||||
ownerId: 'admin_id',
|
||||
shared: false,
|
||||
sharedUsers: [],
|
||||
updatedAt: expect.anything(),
|
||||
});
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SEARCH_INDEX_ALBUM,
|
||||
data: { ids: [albumStub.empty.id] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should require valid userIds', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(
|
||||
sut.create(authStub.admin, {
|
||||
albumName: 'Empty album',
|
||||
sharedWithUserIds: ['user-3'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.get).toHaveBeenCalledWith('user-3');
|
||||
expect(albumMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should prevent updating an album that does not exist', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.user1, 'invalid-id', {
|
||||
albumName: 'new album name',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent updating a not owned album (shared with auth user)', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, albumStub.sharedWithAdmin.id, {
|
||||
albumName: 'new album name',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should require a valid thumbnail asset id', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.hasAsset.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, albumStub.oneAsset.id, {
|
||||
albumThumbnailAssetId: 'not-in-album',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(albumMock.hasAsset).toHaveBeenCalledWith(albumStub.oneAsset.id, 'not-in-album');
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow the owner to update the album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await sut.update(authStub.admin, albumStub.oneAsset.id, {
|
||||
albumName: 'new album name',
|
||||
});
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-4',
|
||||
albumName: 'new album name',
|
||||
});
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SEARCH_INDEX_ALBUM,
|
||||
data: { ids: [albumStub.oneAsset.id] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw an error for an album not found', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(albumMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not let a shared user delete the album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(albumMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should let the owner delete an album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
|
||||
await sut.delete(authStub.admin, albumStub.empty.id);
|
||||
|
||||
expect(albumMock.delete).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUsers', () => {
|
||||
it('should require a valid album id', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
await expect(sut.addUsers(authStub.admin, 'album-1', { sharedUserIds: ['user-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require the user to be the owner', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
await expect(
|
||||
sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the userId is already added', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the userId does not exist', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add valid shared users', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
|
||||
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(userEntityStub.user2);
|
||||
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: albumStub.sharedWithAdmin.id,
|
||||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [userEntityStub.admin, { id: authStub.user2.id }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUser', () => {
|
||||
it('should require a valid album id', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a shared user from an owned album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userEntityStub.user1.id),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: albumStub.sharedWithUser.id,
|
||||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: albumStub.sharedWithUser.id,
|
||||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves using "me"', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: albumStub.sharedWithUser.id,
|
||||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow the owner to be removed', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error for a user not in the album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
188
server/src/domain/album/album.service.ts
Normal file
188
server/src/domain/album/album.service.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { IAssetRepository, mapAsset } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IAlbumRepository } from './album.repository';
|
||||
import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
|
||||
import { AlbumResponseDto, mapAlbum } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumService {
|
||||
constructor(
|
||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
await this.updateInvalidThumbnails();
|
||||
|
||||
let albums: AlbumEntity[];
|
||||
if (assetId) {
|
||||
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
|
||||
} else if (shared === true) {
|
||||
albums = await this.albumRepository.getShared(ownerId);
|
||||
} else if (shared === false) {
|
||||
albums = await this.albumRepository.getNotShared(ownerId);
|
||||
} else {
|
||||
albums = await this.albumRepository.getOwned(ownerId);
|
||||
}
|
||||
|
||||
// Get asset count for each album. Then map the result to an object:
|
||||
// { [albumId]: assetCount }
|
||||
const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id));
|
||||
const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record<string, number>, { albumId, assetCount }) => {
|
||||
obj[albumId] = assetCount;
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return albums.map((album) => {
|
||||
return {
|
||||
...album,
|
||||
assets: album?.assets?.map(mapAsset),
|
||||
sharedLinks: undefined, // Don't return shared links
|
||||
shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0,
|
||||
assetCount: albumsAssetCountObj[album.id],
|
||||
} as AlbumResponseDto;
|
||||
});
|
||||
}
|
||||
|
||||
private async updateInvalidThumbnails(): Promise<number> {
|
||||
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||
|
||||
for (const albumId of invalidAlbumIds) {
|
||||
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
|
||||
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
|
||||
}
|
||||
|
||||
return invalidAlbumIds.length;
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||
for (const userId of dto.sharedWithUserIds || []) {
|
||||
const exists = await this.userRepository.get(userId);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
}
|
||||
|
||||
const album = await this.albumRepository.create({
|
||||
ownerId: authUser.id,
|
||||
albumName: dto.albumName,
|
||||
sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [],
|
||||
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
|
||||
albumThumbnailAssetId: dto.assetIds?.[0] || null,
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } });
|
||||
return mapAlbum(album);
|
||||
}
|
||||
|
||||
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
|
||||
const album = await this.get(id);
|
||||
this.assertOwner(authUser, album);
|
||||
|
||||
if (dto.albumThumbnailAssetId) {
|
||||
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Invalid album thumbnail');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAlbum = await this.albumRepository.update({
|
||||
id: album.id,
|
||||
albumName: dto.albumName,
|
||||
albumThumbnailAssetId: dto.albumThumbnailAssetId,
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } });
|
||||
|
||||
return mapAlbum(updatedAlbum);
|
||||
}
|
||||
|
||||
async delete(authUser: AuthUserDto, id: string): Promise<void> {
|
||||
const [album] = await this.albumRepository.getByIds([id]);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
|
||||
if (album.ownerId !== authUser.id) {
|
||||
throw new ForbiddenException('Album not owned by user');
|
||||
}
|
||||
|
||||
await this.albumRepository.delete(album);
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
|
||||
}
|
||||
|
||||
async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
|
||||
const album = await this.get(id);
|
||||
this.assertOwner(authUser, album);
|
||||
|
||||
for (const userId of dto.sharedUserIds) {
|
||||
const exists = album.sharedUsers.find((user) => user.id === userId);
|
||||
if (exists) {
|
||||
throw new BadRequestException('User already added');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.get(userId);
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
album.sharedUsers.push({ id: userId } as UserEntity);
|
||||
}
|
||||
|
||||
return this.albumRepository
|
||||
.update({
|
||||
id: album.id,
|
||||
updatedAt: new Date(),
|
||||
sharedUsers: album.sharedUsers,
|
||||
})
|
||||
.then(mapAlbum);
|
||||
}
|
||||
|
||||
async removeUser(authUser: AuthUserDto, id: string, userId: string | 'me'): Promise<void> {
|
||||
if (userId === 'me') {
|
||||
userId = authUser.id;
|
||||
}
|
||||
|
||||
const album = await this.get(id);
|
||||
|
||||
if (album.ownerId === userId) {
|
||||
throw new BadRequestException('Cannot remove album owner');
|
||||
}
|
||||
|
||||
const exists = album.sharedUsers.find((user) => user.id === userId);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('Album not shared with user');
|
||||
}
|
||||
|
||||
// non-admin can remove themselves
|
||||
if (authUser.id !== userId) {
|
||||
this.assertOwner(authUser, album);
|
||||
}
|
||||
|
||||
await this.albumRepository.update({
|
||||
id: album.id,
|
||||
updatedAt: new Date(),
|
||||
sharedUsers: album.sharedUsers.filter((user) => user.id !== userId),
|
||||
});
|
||||
}
|
||||
|
||||
private async get(id: string) {
|
||||
const [album] = await this.albumRepository.getByIds([id]);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
private assertOwner(authUser: AuthUserDto, album: AlbumEntity) {
|
||||
if (album.ownerId !== authUser.id) {
|
||||
throw new ForbiddenException('Album not owned by user');
|
||||
}
|
||||
}
|
||||
}
|
||||
8
server/src/domain/album/dto/album-add-users.dto.ts
Normal file
8
server/src/domain/album/dto/album-add-users.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ArrayNotEmpty } from 'class-validator';
|
||||
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
|
||||
|
||||
export class AddUsersDto {
|
||||
@ValidateUUID({ each: true })
|
||||
@ArrayNotEmpty()
|
||||
sharedUserIds!: string[];
|
||||
}
|
||||
16
server/src/domain/album/dto/album-create.dto.ts
Normal file
16
server/src/domain/album/dto/album-create.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateAlbumDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
albumName!: string;
|
||||
|
||||
@ValidateUUID({ optional: true, each: true })
|
||||
sharedWithUserIds?: string[];
|
||||
|
||||
@ValidateUUID({ optional: true, each: true })
|
||||
assetIds?: string[];
|
||||
}
|
||||
12
server/src/domain/album/dto/album-update.dto.ts
Normal file
12
server/src/domain/album/dto/album-update.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional } from 'class-validator';
|
||||
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@IsOptional()
|
||||
@ApiProperty()
|
||||
albumName?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
albumThumbnailAssetId?: string;
|
||||
}
|
||||
26
server/src/domain/album/dto/get-albums.dto.ts
Normal file
26
server/src/domain/album/dto/get-albums.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
|
||||
import { toBoolean } from '@app/immich/utils/transform.util';
|
||||
|
||||
export class GetAlbumsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(toBoolean)
|
||||
@ApiProperty()
|
||||
/**
|
||||
* true: only shared albums
|
||||
* false: only non-shared own albums
|
||||
* undefined: shared and owned albums
|
||||
*/
|
||||
shared?: boolean;
|
||||
|
||||
/**
|
||||
* Only returns albums that contain the asset
|
||||
* Ignores the shared parameter
|
||||
* undefined: get all albums
|
||||
*/
|
||||
@ValidateUUID({ optional: true })
|
||||
assetId?: string;
|
||||
}
|
||||
4
server/src/domain/album/dto/index.ts
Normal file
4
server/src/domain/album/dto/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './album-add-users.dto';
|
||||
export * from './album-create.dto';
|
||||
export * from './album-update.dto';
|
||||
export * from './get-albums.dto';
|
||||
4
server/src/domain/album/index.ts
Normal file
4
server/src/domain/album/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './album.repository';
|
||||
export * from './album.service';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
65
server/src/domain/album/response-dto/album-response.dto.ts
Normal file
65
server/src/domain/album/response-dto/album-response.dto.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AlbumEntity } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { AssetResponseDto, mapAsset } from '../../asset';
|
||||
import { mapUser, UserResponseDto } from '../../user';
|
||||
|
||||
export class AlbumResponseDto {
|
||||
id!: string;
|
||||
ownerId!: string;
|
||||
albumName!: string;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
albumThumbnailAssetId!: string | null;
|
||||
shared!: boolean;
|
||||
sharedUsers!: UserResponseDto[];
|
||||
assets!: AssetResponseDto[];
|
||||
owner!: UserResponseDto;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
|
||||
const sharedUsers: UserResponseDto[] = [];
|
||||
|
||||
entity.sharedUsers?.forEach((user) => {
|
||||
const userDto = mapUser(user);
|
||||
sharedUsers.push(userDto);
|
||||
});
|
||||
|
||||
return {
|
||||
albumName: entity.albumName,
|
||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
id: entity.id,
|
||||
ownerId: entity.ownerId,
|
||||
owner: mapUser(entity.owner),
|
||||
sharedUsers,
|
||||
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
|
||||
assets: entity.assets?.map((asset) => mapAsset(asset)) || [],
|
||||
assetCount: entity.assets?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
|
||||
const sharedUsers: UserResponseDto[] = [];
|
||||
|
||||
entity.sharedUsers?.forEach((user) => {
|
||||
const userDto = mapUser(user);
|
||||
sharedUsers.push(userDto);
|
||||
});
|
||||
|
||||
return {
|
||||
albumName: entity.albumName,
|
||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
id: entity.id,
|
||||
ownerId: entity.ownerId,
|
||||
owner: mapUser(entity.owner),
|
||||
sharedUsers,
|
||||
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
|
||||
assets: [],
|
||||
assetCount: entity.assets?.length || 0,
|
||||
};
|
||||
}
|
||||
1
server/src/domain/album/response-dto/index.ts
Normal file
1
server/src/domain/album/response-dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './album-response.dto';
|
||||
22
server/src/domain/api-key/api-key-response.dto.ts
Normal file
22
server/src/domain/api-key/api-key-response.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { APIKeyEntity } from '@app/infra/entities';
|
||||
|
||||
export class APIKeyCreateResponseDto {
|
||||
secret!: string;
|
||||
apiKey!: APIKeyResponseDto;
|
||||
}
|
||||
|
||||
export class APIKeyResponseDto {
|
||||
id!: string;
|
||||
name!: string;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
export function mapKey(entity: APIKeyEntity): APIKeyResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
};
|
||||
}
|
||||
27
server/src/domain/api-key/api-key.core.ts
Normal file
27
server/src/domain/api-key/api-key.core.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
|
||||
@Injectable()
|
||||
export class APIKeyCore {
|
||||
constructor(private crypto: ICryptoRepository, private repository: IKeyRepository) {}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto | null> {
|
||||
const hashedToken = this.crypto.hashSha256(token);
|
||||
const keyEntity = await this.repository.getKey(hashedToken);
|
||||
if (keyEntity?.user) {
|
||||
const user = keyEntity.user;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
}
|
||||
14
server/src/domain/api-key/api-key.dto.ts
Normal file
14
server/src/domain/api-key/api-key.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class APIKeyCreateDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class APIKeyUpdateDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
16
server/src/domain/api-key/api-key.repository.ts
Normal file
16
server/src/domain/api-key/api-key.repository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { APIKeyEntity } from '@app/infra/entities';
|
||||
|
||||
export const IKeyRepository = 'IKeyRepository';
|
||||
|
||||
export interface IKeyRepository {
|
||||
create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
|
||||
update(userId: string, id: string, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
|
||||
delete(userId: string, id: string): Promise<void>;
|
||||
/**
|
||||
* Includes the hashed `key` for verification
|
||||
* @param id
|
||||
*/
|
||||
getKey(hashedToken: string): Promise<APIKeyEntity | null>;
|
||||
getById(userId: string, id: string): Promise<APIKeyEntity | null>;
|
||||
getByUserId(userId: string): Promise<APIKeyEntity[]>;
|
||||
}
|
||||
111
server/src/domain/api-key/api-key.service.spec.ts
Normal file
111
server/src/domain/api-key/api-key.service.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { authStub, keyStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '@test';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyService } from './api-key.service';
|
||||
|
||||
describe(APIKeyService.name, () => {
|
||||
let sut: APIKeyService;
|
||||
let keyMock: jest.Mocked<IKeyRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
keyMock = newKeyRepositoryMock();
|
||||
sut = new APIKeyService(cryptoMock, keyMock);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new key', async () => {
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
await sut.create(authStub.admin, { name: 'Test Key' });
|
||||
expect(keyMock.create).toHaveBeenCalledWith({
|
||||
key: 'cmFuZG9tLWJ5dGVz (hashed)',
|
||||
name: 'Test Key',
|
||||
userId: authStub.admin.id,
|
||||
});
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not require a name', async () => {
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.create(authStub.admin, {});
|
||||
|
||||
expect(keyMock.create).toHaveBeenCalledWith({
|
||||
key: 'cmFuZG9tLWJ5dGVz (hashed)',
|
||||
name: 'API Key',
|
||||
userId: authStub.admin.id,
|
||||
});
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
keyMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.update(authStub.admin, 'random-guid', { name: 'New Name' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(keyMock.update).not.toHaveBeenCalledWith('random-guid');
|
||||
});
|
||||
|
||||
it('should update a key', async () => {
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.update(authStub.admin, 'random-guid', { name: 'New Name' });
|
||||
|
||||
expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.id, 'random-guid', { name: 'New Name' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
keyMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid');
|
||||
});
|
||||
|
||||
it('should delete a key', async () => {
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.delete(authStub.admin, 'random-guid');
|
||||
|
||||
expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.id, 'random-guid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
keyMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'random-guid');
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.getById(authStub.admin, 'random-guid');
|
||||
|
||||
expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'random-guid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all the keys for a user', async () => {
|
||||
keyMock.getByUserId.mockResolvedValue([keyStub.admin]);
|
||||
|
||||
await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1);
|
||||
|
||||
expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
server/src/domain/api-key/api-key.service.ts
Normal file
58
server/src/domain/api-key/api-key.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { APIKeyCreateResponseDto, APIKeyResponseDto, mapKey } from './api-key-response.dto';
|
||||
import { APIKeyCreateDto } from './api-key.dto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
|
||||
@Injectable()
|
||||
export class APIKeyService {
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private crypto: ICryptoRepository,
|
||||
@Inject(IKeyRepository) private repository: IKeyRepository,
|
||||
) {}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
const secret = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
|
||||
const entity = await this.repository.create({
|
||||
key: this.crypto.hashSha256(secret),
|
||||
name: dto.name || 'API Key',
|
||||
userId: authUser.id,
|
||||
});
|
||||
|
||||
return { secret, apiKey: mapKey(entity) };
|
||||
}
|
||||
|
||||
async update(authUser: AuthUserDto, id: string, dto: APIKeyCreateDto): Promise<APIKeyResponseDto> {
|
||||
const exists = await this.repository.getById(authUser.id, id);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('API Key not found');
|
||||
}
|
||||
|
||||
return this.repository.update(authUser.id, id, {
|
||||
name: dto.name,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(authUser: AuthUserDto, id: string): Promise<void> {
|
||||
const exists = await this.repository.getById(authUser.id, id);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('API Key not found');
|
||||
}
|
||||
|
||||
await this.repository.delete(authUser.id, id);
|
||||
}
|
||||
|
||||
async getById(authUser: AuthUserDto, id: string): Promise<APIKeyResponseDto> {
|
||||
const key = await this.repository.getById(authUser.id, id);
|
||||
if (!key) {
|
||||
throw new BadRequestException('API Key not found');
|
||||
}
|
||||
return mapKey(key);
|
||||
}
|
||||
|
||||
async getAll(authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
|
||||
const keys = await this.repository.getByUserId(authUser.id);
|
||||
return keys.map(mapKey);
|
||||
}
|
||||
}
|
||||
4
server/src/domain/api-key/index.ts
Normal file
4
server/src/domain/api-key/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api-key-response.dto';
|
||||
export * from './api-key.dto';
|
||||
export * from './api-key.repository';
|
||||
export * from './api-key.service';
|
||||
54
server/src/domain/asset/asset.repository.ts
Normal file
54
server/src/domain/asset/asset.repository.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { Paginated, PaginationOptions } from '../domain.util';
|
||||
|
||||
export interface AssetSearchOptions {
|
||||
isVisible?: boolean;
|
||||
type?: AssetType;
|
||||
}
|
||||
|
||||
export interface LivePhotoSearchOptions {
|
||||
ownerId: string;
|
||||
livePhotoCID: string;
|
||||
otherAssetId: string;
|
||||
type: AssetType;
|
||||
}
|
||||
|
||||
export interface MapMarkerSearchOptions {
|
||||
isFavorite?: boolean;
|
||||
fileCreatedBefore?: Date;
|
||||
fileCreatedAfter?: Date;
|
||||
}
|
||||
|
||||
export interface MapMarker {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export enum WithoutProperty {
|
||||
THUMBNAIL = 'thumbnail',
|
||||
ENCODED_VIDEO = 'encoded-video',
|
||||
EXIF = 'exif',
|
||||
CLIP_ENCODING = 'clip-embedding',
|
||||
OBJECT_TAGS = 'object-tags',
|
||||
FACES = 'faces',
|
||||
SIDECAR = 'sidecar',
|
||||
}
|
||||
|
||||
export enum WithProperty {
|
||||
SIDECAR = 'sidecar',
|
||||
}
|
||||
|
||||
export const IAssetRepository = 'IAssetRepository';
|
||||
|
||||
export interface IAssetRepository {
|
||||
getByIds(ids: string[]): Promise<AssetEntity[]>;
|
||||
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
|
||||
getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
|
||||
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
||||
deleteAll(ownerId: string): Promise<void>;
|
||||
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
|
||||
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
|
||||
}
|
||||
41
server/src/domain/asset/asset.service.spec.ts
Normal file
41
server/src/domain/asset/asset.service.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { assetEntityStub, authStub, newAssetRepositoryMock } from '@test';
|
||||
import { AssetService, IAssetRepository } from '.';
|
||||
|
||||
describe(AssetService.name, () => {
|
||||
let sut: AssetService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
sut = new AssetService(assetMock);
|
||||
});
|
||||
|
||||
describe('get map markers', () => {
|
||||
it('should get geo information of assets', async () => {
|
||||
assetMock.getMapMarkers.mockResolvedValue(
|
||||
[assetEntityStub.withLocation].map((asset) => ({
|
||||
id: asset.id,
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
})),
|
||||
);
|
||||
|
||||
const markers = await sut.getMapMarkers(authStub.user1, {});
|
||||
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual({
|
||||
id: assetEntityStub.withLocation.id,
|
||||
lat: 100,
|
||||
lon: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
13
server/src/domain/asset/asset.service.ts
Normal file
13
server/src/domain/asset/asset.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IAssetRepository } from './asset.repository';
|
||||
import { MapMarkerDto } from './dto/map-marker.dto';
|
||||
import { MapMarkerResponseDto } from './response-dto';
|
||||
|
||||
export class AssetService {
|
||||
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
|
||||
|
||||
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
|
||||
return this.assetRepository.getMapMarkers(authUser.id, options);
|
||||
}
|
||||
}
|
||||
6
server/src/domain/asset/dto/asset-ids.dto.ts
Normal file
6
server/src/domain/asset/dto/asset-ids.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
|
||||
|
||||
export class AssetIdsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
||||
2
server/src/domain/asset/dto/index.ts
Normal file
2
server/src/domain/asset/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './asset-ids.dto';
|
||||
export * from './map-marker.dto';
|
||||
22
server/src/domain/asset/dto/map-marker.dto.ts
Normal file
22
server/src/domain/asset/dto/map-marker.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsDate, IsOptional } from 'class-validator';
|
||||
import { toBoolean } from '@app/immich/utils/transform.util';
|
||||
|
||||
export class MapMarkerDto {
|
||||
@ApiProperty()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(toBoolean)
|
||||
isFavorite?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
fileCreatedAfter?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
fileCreatedBefore?: Date;
|
||||
}
|
||||
4
server/src/domain/asset/index.ts
Normal file
4
server/src/domain/asset/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './asset.repository';
|
||||
export * from './asset.service';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum AssetIdErrorReason {
|
||||
DUPLICATE = 'duplicate',
|
||||
NO_PERMISSION = 'no_permission',
|
||||
NOT_FOUND = 'not_found',
|
||||
}
|
||||
|
||||
export class AssetIdsResponseDto {
|
||||
assetId!: string;
|
||||
success!: boolean;
|
||||
error?: AssetIdErrorReason;
|
||||
}
|
||||
85
server/src/domain/asset/response-dto/asset-response.dto.ts
Normal file
85
server/src/domain/asset/response-dto/asset-response.dto.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { mapFace, PersonResponseDto } from '../../person';
|
||||
import { mapTag, TagResponseDto } from '../../tag';
|
||||
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||
import { mapSmartInfo, SmartInfoResponseDto } from './smart-info-response.dto';
|
||||
|
||||
export class AssetResponseDto {
|
||||
id!: string;
|
||||
deviceAssetId!: string;
|
||||
ownerId!: string;
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
||||
type!: AssetType;
|
||||
originalPath!: string;
|
||||
originalFileName!: string;
|
||||
resized!: boolean;
|
||||
fileCreatedAt!: Date;
|
||||
fileModifiedAt!: Date;
|
||||
updatedAt!: Date;
|
||||
isFavorite!: boolean;
|
||||
isArchived!: boolean;
|
||||
mimeType!: string | null;
|
||||
duration!: string;
|
||||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
livePhotoVideoId?: string | null;
|
||||
tags?: TagResponseDto[];
|
||||
people?: PersonResponseDto[];
|
||||
/**base64 encoded sha1 hash */
|
||||
checksum!: string;
|
||||
}
|
||||
|
||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.ownerId,
|
||||
deviceId: entity.deviceId,
|
||||
type: entity.type,
|
||||
originalPath: entity.originalPath,
|
||||
originalFileName: entity.originalFileName,
|
||||
resized: !!entity.resizePath,
|
||||
fileCreatedAt: entity.fileCreatedAt,
|
||||
fileModifiedAt: entity.fileModifiedAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
isFavorite: entity.isFavorite,
|
||||
isArchived: entity.isArchived,
|
||||
mimeType: entity.mimeType,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map(mapTag),
|
||||
people: entity.faces?.map(mapFace),
|
||||
checksum: entity.checksum.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.ownerId,
|
||||
deviceId: entity.deviceId,
|
||||
type: entity.type,
|
||||
originalPath: entity.originalPath,
|
||||
originalFileName: entity.originalFileName,
|
||||
resized: !!entity.resizePath,
|
||||
fileCreatedAt: entity.fileCreatedAt,
|
||||
fileModifiedAt: entity.fileModifiedAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
isFavorite: entity.isFavorite,
|
||||
isArchived: entity.isArchived,
|
||||
mimeType: entity.mimeType,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
exifInfo: undefined,
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map(mapTag),
|
||||
people: entity.faces?.map(mapFace),
|
||||
checksum: entity.checksum.toString('base64'),
|
||||
};
|
||||
}
|
||||
52
server/src/domain/asset/response-dto/exif-response.dto.ts
Normal file
52
server/src/domain/asset/response-dto/exif-response.dto.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ExifEntity } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ExifResponseDto {
|
||||
make?: string | null = null;
|
||||
model?: string | null = null;
|
||||
exifImageWidth?: number | null = null;
|
||||
exifImageHeight?: number | null = null;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
fileSizeInByte?: number | null = null;
|
||||
orientation?: string | null = null;
|
||||
dateTimeOriginal?: Date | null = null;
|
||||
modifyDate?: Date | null = null;
|
||||
timeZone?: string | null = null;
|
||||
lensModel?: string | null = null;
|
||||
fNumber?: number | null = null;
|
||||
focalLength?: number | null = null;
|
||||
iso?: number | null = null;
|
||||
exposureTime?: string | null = null;
|
||||
latitude?: number | null = null;
|
||||
longitude?: number | null = null;
|
||||
city?: string | null = null;
|
||||
state?: string | null = null;
|
||||
country?: string | null = null;
|
||||
description?: string | null = null;
|
||||
}
|
||||
|
||||
export function mapExif(entity: ExifEntity): ExifResponseDto {
|
||||
return {
|
||||
make: entity.make,
|
||||
model: entity.model,
|
||||
exifImageWidth: entity.exifImageWidth,
|
||||
exifImageHeight: entity.exifImageHeight,
|
||||
fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
|
||||
orientation: entity.orientation,
|
||||
dateTimeOriginal: entity.dateTimeOriginal,
|
||||
modifyDate: entity.modifyDate,
|
||||
timeZone: entity.timeZone,
|
||||
lensModel: entity.lensModel,
|
||||
fNumber: entity.fNumber,
|
||||
focalLength: entity.focalLength,
|
||||
iso: entity.iso,
|
||||
exposureTime: entity.exposureTime,
|
||||
latitude: entity.latitude,
|
||||
longitude: entity.longitude,
|
||||
city: entity.city,
|
||||
state: entity.state,
|
||||
country: entity.country,
|
||||
description: entity.description,
|
||||
};
|
||||
}
|
||||
5
server/src/domain/asset/response-dto/index.ts
Normal file
5
server/src/domain/asset/response-dto/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './asset-ids-response.dto';
|
||||
export * from './asset-response.dto';
|
||||
export * from './exif-response.dto';
|
||||
export * from './map-marker-response.dto';
|
||||
export * from './smart-info-response.dto';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MapMarkerResponseDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'double' })
|
||||
lat!: number;
|
||||
|
||||
@ApiProperty({ format: 'double' })
|
||||
lon!: number;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SmartInfoEntity } from '@app/infra/entities';
|
||||
|
||||
export class SmartInfoResponseDto {
|
||||
tags?: string[] | null;
|
||||
objects?: string[] | null;
|
||||
}
|
||||
|
||||
export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto {
|
||||
return {
|
||||
tags: entity.tags,
|
||||
objects: entity.objects,
|
||||
};
|
||||
}
|
||||
8
server/src/domain/auth/auth.constant.ts
Normal file
8
server/src/domain/auth/auth.constant.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
|
||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
|
||||
export const IMMICH_API_KEY_NAME = 'api_key';
|
||||
export const IMMICH_API_KEY_HEADER = 'x-api-key';
|
||||
export enum AuthType {
|
||||
PASSWORD = 'password',
|
||||
OAUTH = 'oauth',
|
||||
}
|
||||
62
server/src/domain/auth/auth.core.ts
Normal file
62
server/src/domain/auth/auth.core.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
|
||||
export interface LoginDetails {
|
||||
isSecure: boolean;
|
||||
clientIp: string;
|
||||
deviceType: string;
|
||||
deviceOS: string;
|
||||
}
|
||||
|
||||
export class AuthCore {
|
||||
private userTokenCore: UserTokenCore;
|
||||
constructor(
|
||||
private cryptoRepository: ICryptoRepository,
|
||||
configRepository: ISystemConfigRepository,
|
||||
userTokenRepository: IUserTokenRepository,
|
||||
private config: SystemConfig,
|
||||
) {
|
||||
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
|
||||
const configCore = new SystemConfigCore(configRepository);
|
||||
configCore.config$.subscribe((config) => (this.config = config));
|
||||
}
|
||||
|
||||
isPasswordLoginEnabled() {
|
||||
return this.config.passwordLogin.enabled;
|
||||
}
|
||||
|
||||
getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
|
||||
const maxAge = 400 * 24 * 3600; // 400 days
|
||||
|
||||
let authTypeCookie = '';
|
||||
let accessTokenCookie = '';
|
||||
|
||||
if (isSecure) {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
} else {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
}
|
||||
return [accessTokenCookie, authTypeCookie];
|
||||
}
|
||||
|
||||
async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
|
||||
const accessToken = await this.userTokenCore.create(user, loginDetails);
|
||||
const response = mapLoginResponse(user, accessToken);
|
||||
const cookie = this.getCookies(response, authType, loginDetails);
|
||||
return { response, cookie };
|
||||
}
|
||||
|
||||
validatePassword(inputPassword: string, user: UserEntity): boolean {
|
||||
if (!user || !user.password) {
|
||||
return false;
|
||||
}
|
||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
||||
}
|
||||
}
|
||||
388
server/src/domain/auth/auth.service.spec.ts
Normal file
388
server/src/domain/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import { Socket } from 'socket.io';
|
||||
import {
|
||||
authStub,
|
||||
keyStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newKeyRepositoryMock,
|
||||
newSharedLinkRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
newUserTokenRepositoryMock,
|
||||
sharedLinkStub,
|
||||
systemConfigStub,
|
||||
userEntityStub,
|
||||
userTokenEntityStub,
|
||||
} from '@test';
|
||||
import { IKeyRepository } from '../api-key';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { ISharedLinkRepository } from '../shared-link';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IUserTokenRepository } from '../user-token';
|
||||
import { AuthType } from './auth.constant';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthUserDto, SignUpDto } from './dto';
|
||||
|
||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
const loginDetails = {
|
||||
isSecure: true,
|
||||
clientIp: '127.0.0.1',
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
};
|
||||
|
||||
const fixtures = {
|
||||
login: {
|
||||
email,
|
||||
password: 'password',
|
||||
},
|
||||
};
|
||||
|
||||
describe('AuthService', () => {
|
||||
let sut: AuthService;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let userTokenMock: jest.Mocked<IUserTokenRepository>;
|
||||
let shareMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let keyMock: jest.Mocked<IKeyRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => AuthService;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
|
||||
|
||||
jest.spyOn(generators, 'state').mockReturnValue('state');
|
||||
jest.spyOn(Issuer, 'discover').mockResolvedValue({
|
||||
id_token_signing_alg_values_supported: ['HS256'],
|
||||
Client: jest.fn().mockResolvedValue({
|
||||
issuer: {
|
||||
metadata: {
|
||||
end_session_endpoint: 'http://end-session-endpoint',
|
||||
},
|
||||
},
|
||||
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
|
||||
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
|
||||
callback: callbackMock,
|
||||
userinfo: jest.fn().mockResolvedValue({ sub, email }),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
shareMock = newSharedLinkRepositoryMock();
|
||||
keyMock = newKeyRepositoryMock();
|
||||
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config);
|
||||
|
||||
sut = create(systemConfigStub.enabled);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
sut = create(systemConfigStub.disabled);
|
||||
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check the user exists', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should check the user has a password', async () => {
|
||||
userMock.getByEmail.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should generate the cookie headers (insecure)', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(
|
||||
sut.login(fixtures.login, {
|
||||
clientIp: '127.0.0.1',
|
||||
isSecure: false,
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
}),
|
||||
).resolves.toEqual(loginResponseStub.user1insecure);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('should change the password', async () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
|
||||
await sut.changePassword(authUser, dto);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
|
||||
expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||
});
|
||||
|
||||
it('should throw when auth user email is not found', async () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw when password does not match existing password', async () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
cryptoMock.compareBcrypt.mockReturnValue(false);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
|
||||
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw when user does not have a password', async () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: '',
|
||||
} as UserEntity);
|
||||
|
||||
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should return the end session endpoint', async () => {
|
||||
const authUser = { id: '123' } as AuthUserDto;
|
||||
|
||||
await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the default redirect', async () => {
|
||||
const authUser = { id: '123' } as AuthUserDto;
|
||||
|
||||
await expect(sut.logout(authUser, AuthType.PASSWORD)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: '/auth/login?autoLaunch=0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete the access token', async () => {
|
||||
const authUser = { id: '123', accessTokenId: 'token123' } as AuthUserDto;
|
||||
|
||||
await expect(sut.logout(authUser, AuthType.PASSWORD)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: '/auth/login?autoLaunch=0',
|
||||
});
|
||||
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminSignUp', () => {
|
||||
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
|
||||
|
||||
it('should only allow one admin', async () => {
|
||||
userMock.getAdmin.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getAdmin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sign up the admin', async () => {
|
||||
userMock.getAdmin.mockResolvedValue(null);
|
||||
userMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: new Date('2021-01-01') } as UserEntity);
|
||||
await expect(sut.adminSignUp(dto)).resolves.toEqual({
|
||||
id: 'admin',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
email: 'test@immich.com',
|
||||
firstName: 'immich',
|
||||
lastName: 'admin',
|
||||
});
|
||||
expect(userMock.getAdmin).toHaveBeenCalled();
|
||||
expect(userMock.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - socket connections', () => {
|
||||
it('should throw token is not provided', async () => {
|
||||
await expect(sut.validate({}, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should validate using authorization header', async () => {
|
||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
|
||||
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - shared key', () => {
|
||||
it('should not accept a non-existent key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should not accept an expired key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should not accept a key without a user', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should accept a base64url key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(userEntityStub.admin);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.adminSharedLink);
|
||||
expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
||||
});
|
||||
|
||||
it('should accept a hex key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(userEntityStub.admin);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.adminSharedLink);
|
||||
expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - user token', () => {
|
||||
it('should throw if no token is found', async () => {
|
||||
userTokenMock.getByToken.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
|
||||
it('should update when access time exceeds an hour', async () => {
|
||||
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.inactiveToken);
|
||||
userTokenMock.save.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
|
||||
id: 'not_active',
|
||||
token: 'auth_token',
|
||||
userId: 'user-id',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: expect.any(Date),
|
||||
deviceOS: 'Android',
|
||||
deviceType: 'Mobile',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - api key', () => {
|
||||
it('should throw an error if no api key is found', async () => {
|
||||
keyMock.getKey.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
keyMock.getKey.mockResolvedValue(keyStub.admin);
|
||||
const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.admin);
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDevices', () => {
|
||||
it('should get the devices', async () => {
|
||||
userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.userToken, userTokenEntityStub.inactiveToken]);
|
||||
await expect(sut.getDevices(authStub.user1)).resolves.toEqual([
|
||||
{
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
current: true,
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
id: 'token-id',
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
current: false,
|
||||
deviceOS: 'Android',
|
||||
deviceType: 'Mobile',
|
||||
id: 'not_active',
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutDevices', () => {
|
||||
it('should logout all devices', async () => {
|
||||
userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.inactiveToken, userTokenEntityStub.userToken]);
|
||||
|
||||
await sut.logoutDevices(authStub.user1);
|
||||
|
||||
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'not_active');
|
||||
expect(userTokenMock.delete).not.toHaveBeenCalledWith(authStub.user1.id, 'token-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutDevice', () => {
|
||||
it('should logout the device', async () => {
|
||||
await sut.logoutDevice(authStub.user1, 'token-1');
|
||||
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
190
server/src/domain/auth/auth.service.ts
Normal file
190
server/src/domain/auth/auth.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { SystemConfig } from '@app/infra/entities';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { OAuthCore } from '../oauth/oauth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER } from './auth.constant';
|
||||
import { AuthCore, LoginDetails } from './auth.core';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
import cookieParser from 'cookie';
|
||||
import { ISharedLinkRepository, SharedLinkCore } from '../shared-link';
|
||||
import { APIKeyCore } from '../api-key/api-key.core';
|
||||
import { IKeyRepository } from '../api-key';
|
||||
import { AuthDeviceResponseDto, mapUserToken } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private userTokenCore: UserTokenCore;
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
private shareCore: SharedLinkCore;
|
||||
private keyCore: APIKeyCore;
|
||||
|
||||
private logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
||||
@Inject(ISharedLinkRepository) shareRepository: ISharedLinkRepository,
|
||||
@Inject(IKeyRepository) keyRepository: IKeyRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG)
|
||||
initialConfig: SystemConfig,
|
||||
) {
|
||||
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
this.shareCore = new SharedLinkCore(shareRepository, cryptoRepository);
|
||||
this.keyCore = new APIKeyCore(cryptoRepository, keyRepository);
|
||||
}
|
||||
|
||||
public async login(
|
||||
loginCredential: LoginCredentialDto,
|
||||
loginDetails: LoginDetails,
|
||||
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||
if (!this.authCore.isPasswordLoginEnabled()) {
|
||||
throw new UnauthorizedException('Password login has been disabled');
|
||||
}
|
||||
|
||||
let user = await this.userCore.getByEmail(loginCredential.email, true);
|
||||
if (user) {
|
||||
const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user);
|
||||
if (!isAuthenticated) {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(
|
||||
`Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
|
||||
);
|
||||
throw new BadRequestException('Incorrect email or password');
|
||||
}
|
||||
|
||||
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
|
||||
}
|
||||
|
||||
public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||
if (authUser.accessTokenId) {
|
||||
await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
|
||||
}
|
||||
|
||||
if (authType === AuthType.OAUTH) {
|
||||
const url = await this.oauthCore.getLogoutEndpoint();
|
||||
if (url) {
|
||||
return { successful: true, redirectUri: url };
|
||||
}
|
||||
}
|
||||
|
||||
return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
|
||||
}
|
||||
|
||||
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
|
||||
const { password, newPassword } = dto;
|
||||
const user = await this.userCore.getByEmail(authUser.email, true);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const valid = this.authCore.validatePassword(password, user);
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Wrong password');
|
||||
}
|
||||
|
||||
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
|
||||
}
|
||||
|
||||
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
|
||||
const adminUser = await this.userCore.getAdmin();
|
||||
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
}
|
||||
|
||||
try {
|
||||
const admin = await this.userCore.createUser({
|
||||
isAdmin: true,
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
password: dto.password,
|
||||
storageLabel: 'admin',
|
||||
});
|
||||
|
||||
return mapAdminSignupResponse(admin);
|
||||
} catch (error) {
|
||||
this.logger.error(`Unable to register admin user: ${error}`, (error as Error).stack);
|
||||
throw new InternalServerErrorException('Failed to register new admin user');
|
||||
}
|
||||
}
|
||||
|
||||
public async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
|
||||
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
|
||||
const userToken = (headers['x-immich-user-token'] ||
|
||||
params.userToken ||
|
||||
this.getBearerToken(headers) ||
|
||||
this.getCookieToken(headers)) as string;
|
||||
const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string;
|
||||
|
||||
if (shareKey) {
|
||||
return this.shareCore.validate(shareKey);
|
||||
}
|
||||
|
||||
if (userToken) {
|
||||
return this.userTokenCore.validate(userToken);
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
return this.keyCore.validate(apiKey);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
|
||||
const userTokens = await this.userTokenCore.getAll(authUser.id);
|
||||
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
|
||||
}
|
||||
|
||||
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
|
||||
await this.userTokenCore.delete(authUser.id, deviceId);
|
||||
}
|
||||
|
||||
async logoutDevices(authUser: AuthUserDto): Promise<void> {
|
||||
const devices = await this.userTokenCore.getAll(authUser.id);
|
||||
for (const device of devices) {
|
||||
if (device.id === authUser.accessTokenId) {
|
||||
continue;
|
||||
}
|
||||
await this.userTokenCore.delete(authUser.id, device.id);
|
||||
}
|
||||
}
|
||||
|
||||
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
||||
const [type, token] = (headers.authorization || '').split(' ');
|
||||
if (type.toLowerCase() === 'bearer') {
|
||||
return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getCookieToken(headers: IncomingHttpHeaders): string | null {
|
||||
const cookies = cookieParser.parse(headers.cookie || '');
|
||||
return cookies[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
}
|
||||
11
server/src/domain/auth/dto/auth-user.dto.ts
Normal file
11
server/src/domain/auth/dto/auth-user.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class AuthUserDto {
|
||||
id!: string;
|
||||
email!: string;
|
||||
isAdmin!: boolean;
|
||||
isPublicUser?: boolean;
|
||||
sharedLinkId?: string;
|
||||
isAllowUpload?: boolean;
|
||||
isAllowDownload?: boolean;
|
||||
isShowExif?: boolean;
|
||||
accessTokenId?: string;
|
||||
}
|
||||
15
server/src/domain/auth/dto/change-password.dto.ts
Normal file
15
server/src/domain/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
@ApiProperty({ example: 'password' })
|
||||
newPassword!: string;
|
||||
}
|
||||
4
server/src/domain/auth/dto/index.ts
Normal file
4
server/src/domain/auth/dto/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './auth-user.dto';
|
||||
export * from './change-password.dto';
|
||||
export * from './login-credential.dto';
|
||||
export * from './sign-up.dto';
|
||||
33
server/src/domain/auth/dto/login-credential.dto.spec.ts
Normal file
33
server/src/domain/auth/dto/login-credential.dto.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { LoginCredentialDto } from './login-credential.dto';
|
||||
|
||||
describe('LoginCredentialDto', () => {
|
||||
it('should fail without an email', () => {
|
||||
const dto = plainToInstance(LoginCredentialDto, { password: 'password' });
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toEqual('email');
|
||||
});
|
||||
|
||||
it('should fail with an invalid email', () => {
|
||||
const dto = plainToInstance(LoginCredentialDto, { email: 'invalid.com', password: 'password' });
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toEqual('email');
|
||||
});
|
||||
|
||||
it('should make the email all lowercase', () => {
|
||||
const dto = plainToInstance(LoginCredentialDto, { email: 'TeSt@ImMiCh.com', password: 'password' });
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(dto.email).toEqual('test@immich.com');
|
||||
});
|
||||
|
||||
it('should fail without a password', () => {
|
||||
const dto = plainToInstance(LoginCredentialDto, { email: 'test@immich.com', password: '' });
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toEqual('password');
|
||||
});
|
||||
});
|
||||
15
server/src/domain/auth/dto/login-credential.dto.ts
Normal file
15
server/src/domain/auth/dto/login-credential.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginCredentialDto {
|
||||
@IsEmail()
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
@Transform(({ value }) => value.toLowerCase())
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password!: string;
|
||||
}
|
||||
44
server/src/domain/auth/dto/sign-up.dto.spec.ts
Normal file
44
server/src/domain/auth/dto/sign-up.dto.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { SignUpDto } from './sign-up.dto';
|
||||
|
||||
describe('SignUpDto', () => {
|
||||
it('should require all fields', () => {
|
||||
const dto = plainToInstance(SignUpDto, {
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
});
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(4);
|
||||
expect(errors[0].property).toEqual('email');
|
||||
expect(errors[1].property).toEqual('password');
|
||||
expect(errors[2].property).toEqual('firstName');
|
||||
expect(errors[3].property).toEqual('lastName');
|
||||
});
|
||||
|
||||
it('should require a valid email', () => {
|
||||
const dto = plainToInstance(SignUpDto, {
|
||||
email: 'immich.com',
|
||||
password: 'password',
|
||||
firstName: 'first name',
|
||||
lastName: 'last name',
|
||||
});
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toEqual('email');
|
||||
});
|
||||
|
||||
it('should make the email all lowercase', () => {
|
||||
const dto = plainToInstance(SignUpDto, {
|
||||
email: 'TeSt@ImMiCh.com',
|
||||
password: 'password',
|
||||
firstName: 'first name',
|
||||
lastName: 'last name',
|
||||
});
|
||||
const errors = validateSync(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(dto.email).toEqual('test@immich.com');
|
||||
});
|
||||
});
|
||||
25
server/src/domain/auth/dto/sign-up.dto.ts
Normal file
25
server/src/domain/auth/dto/sign-up.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SignUpDto {
|
||||
@IsEmail()
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
@Transform(({ value }) => value.toLowerCase())
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'Admin' })
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
lastName!: string;
|
||||
}
|
||||
5
server/src/domain/auth/index.ts
Normal file
5
server/src/domain/auth/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './auth.constant';
|
||||
export * from './auth.core';
|
||||
export * from './auth.service';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { UserEntity } from '@app/infra/entities';
|
||||
|
||||
export class AdminSignupResponseDto {
|
||||
id!: string;
|
||||
email!: string;
|
||||
firstName!: string;
|
||||
lastName!: string;
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
export function mapAdminSignupResponse(entity: UserEntity): AdminSignupResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
createdAt: entity.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { UserTokenEntity } from '@app/infra/entities';
|
||||
|
||||
export class AuthDeviceResponseDto {
|
||||
id!: string;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
current!: boolean;
|
||||
deviceType!: string;
|
||||
deviceOS!: string;
|
||||
}
|
||||
|
||||
export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({
|
||||
id: entity.id,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
current: currentId === entity.id,
|
||||
deviceOS: entity.deviceOS,
|
||||
deviceType: entity.deviceType,
|
||||
});
|
||||
5
server/src/domain/auth/response-dto/index.ts
Normal file
5
server/src/domain/auth/response-dto/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './admin-signup-response.dto';
|
||||
export * from './auth-device-response.dto';
|
||||
export * from './login-response.dto';
|
||||
export * from './logout-response.dto';
|
||||
export * from './validate-asset-token-response.dto';
|
||||
41
server/src/domain/auth/response-dto/login-response.dto.ts
Normal file
41
server/src/domain/auth/response-dto/login-response.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { UserEntity } from '@app/infra/entities';
|
||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiResponseProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
userId!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
userEmail!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
firstName!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
lastName!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
profileImagePath!: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
isAdmin!: boolean;
|
||||
|
||||
@ApiResponseProperty()
|
||||
shouldChangePassword!: boolean;
|
||||
}
|
||||
|
||||
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
|
||||
return {
|
||||
accessToken: accessToken,
|
||||
userId: entity.id,
|
||||
userEmail: entity.email,
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
isAdmin: entity.isAdmin,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class LogoutResponseDto {
|
||||
successful!: boolean;
|
||||
redirectUri!: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class ValidateAccessTokenResponseDto {
|
||||
authStatus!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const ICommunicationRepository = 'ICommunicationRepository';
|
||||
|
||||
export enum CommunicationEvent {
|
||||
UPLOAD_SUCCESS = 'on_upload_success',
|
||||
}
|
||||
|
||||
export interface ICommunicationRepository {
|
||||
send(event: CommunicationEvent, userId: string, data: any): void;
|
||||
}
|
||||
1
server/src/domain/communication/index.ts
Normal file
1
server/src/domain/communication/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './communication.repository';
|
||||
8
server/src/domain/crypto/crypto.repository.ts
Normal file
8
server/src/domain/crypto/crypto.repository.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const ICryptoRepository = 'ICryptoRepository';
|
||||
|
||||
export interface ICryptoRepository {
|
||||
randomBytes(size: number): Buffer;
|
||||
hashSha256(data: string): string;
|
||||
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareBcrypt(data: string | Buffer, encrypted: string): boolean;
|
||||
}
|
||||
1
server/src/domain/crypto/index.ts
Normal file
1
server/src/domain/crypto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './crypto.repository';
|
||||
43
server/src/domain/domain.config.ts
Normal file
43
server/src/domain/domain.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// TODO: remove nestjs references from domain
|
||||
import { LogLevel } from '@nestjs/common';
|
||||
import { ConfigModuleOptions } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
|
||||
const WHEN_DB_URL_SET = Joi.when('DB_URL', {
|
||||
is: Joi.exist(),
|
||||
then: Joi.string().optional(),
|
||||
otherwise: Joi.string().required(),
|
||||
});
|
||||
|
||||
export const immichAppConfig: ConfigModuleOptions = {
|
||||
envFilePath: '.env',
|
||||
isGlobal: true,
|
||||
validationSchema: Joi.object({
|
||||
NODE_ENV: Joi.string().required().valid('development', 'production', 'staging').default('development'),
|
||||
DB_USERNAME: WHEN_DB_URL_SET,
|
||||
DB_PASSWORD: WHEN_DB_URL_SET,
|
||||
DB_DATABASE_NAME: WHEN_DB_URL_SET,
|
||||
DB_URL: Joi.string().optional(),
|
||||
TYPESENSE_API_KEY: Joi.when('TYPESENSE_ENABLED', {
|
||||
is: 'false',
|
||||
then: Joi.string().optional(),
|
||||
otherwise: Joi.string().required(),
|
||||
}),
|
||||
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
|
||||
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
|
||||
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'),
|
||||
MACHINE_LEARNING_PORT: Joi.number().optional(),
|
||||
MICROSERVICES_PORT: Joi.number().optional(),
|
||||
SERVER_PORT: Joi.number().optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export function getLogLevels() {
|
||||
const LOG_LEVELS: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error'];
|
||||
let logLevel = process.env.LOG_LEVEL || 'log';
|
||||
if (logLevel === 'simple') {
|
||||
logLevel = 'log';
|
||||
}
|
||||
const logLevelIndex = LOG_LEVELS.indexOf(logLevel as LogLevel);
|
||||
return logLevelIndex === -1 ? [] : LOG_LEVELS.slice(logLevelIndex);
|
||||
}
|
||||
29
server/src/domain/domain.constant.ts
Normal file
29
server/src/domain/domain.constant.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import pkg from 'src/../../package.json';
|
||||
|
||||
const [major, minor, patch] = pkg.version.split('.');
|
||||
|
||||
export interface IServerVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: Number(major),
|
||||
minor: Number(minor),
|
||||
patch: Number(patch),
|
||||
};
|
||||
|
||||
export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`;
|
||||
|
||||
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload';
|
||||
|
||||
export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
|
||||
export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
|
||||
|
||||
export function assertMachineLearningEnabled() {
|
||||
if (!MACHINE_LEARNING_ENABLED) {
|
||||
throw new BadRequestException('Machine learning is not enabled.');
|
||||
}
|
||||
}
|
||||
70
server/src/domain/domain.module.ts
Normal file
70
server/src/domain/domain.module.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { DynamicModule, Global, Module, ModuleMetadata, OnApplicationShutdown, Provider } from '@nestjs/common';
|
||||
import { AlbumService } from './album';
|
||||
import { APIKeyService } from './api-key';
|
||||
import { AssetService } from './asset';
|
||||
import { AuthService } from './auth';
|
||||
import { FacialRecognitionService } from './facial-recognition';
|
||||
import { JobService } from './job';
|
||||
import { MediaService } from './media';
|
||||
import { MetadataService } from './metadata';
|
||||
import { OAuthService } from './oauth';
|
||||
import { PartnerService } from './partner';
|
||||
import { PersonService } from './person';
|
||||
import { SearchService } from './search';
|
||||
import { ServerInfoService } from './server-info';
|
||||
import { SharedLinkService } from './shared-link';
|
||||
import { SmartInfoService } from './smart-info';
|
||||
import { StorageService } from './storage';
|
||||
import { StorageTemplateService } from './storage-template';
|
||||
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
||||
import { TagService } from './tag';
|
||||
import { UserService } from './user';
|
||||
|
||||
const providers: Provider[] = [
|
||||
AlbumService,
|
||||
APIKeyService,
|
||||
AssetService,
|
||||
AuthService,
|
||||
FacialRecognitionService,
|
||||
JobService,
|
||||
MediaService,
|
||||
MetadataService,
|
||||
OAuthService,
|
||||
PersonService,
|
||||
PartnerService,
|
||||
SearchService,
|
||||
ServerInfoService,
|
||||
SharedLinkService,
|
||||
SmartInfoService,
|
||||
StorageService,
|
||||
StorageTemplateService,
|
||||
SystemConfigService,
|
||||
TagService,
|
||||
UserService,
|
||||
{
|
||||
provide: INITIAL_SYSTEM_CONFIG,
|
||||
inject: [SystemConfigService],
|
||||
useFactory: async (configService: SystemConfigService) => {
|
||||
return configService.getConfig();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@Global()
|
||||
@Module({})
|
||||
export class DomainModule implements OnApplicationShutdown {
|
||||
constructor(private searchService: SearchService) {}
|
||||
|
||||
static register(options: Pick<ModuleMetadata, 'imports'>): DynamicModule {
|
||||
return {
|
||||
module: DomainModule,
|
||||
imports: options.imports,
|
||||
providers: [...providers],
|
||||
exports: [...providers],
|
||||
};
|
||||
}
|
||||
|
||||
onApplicationShutdown() {
|
||||
this.searchService.teardown();
|
||||
}
|
||||
}
|
||||
59
server/src/domain/domain.util.ts
Normal file
59
server/src/domain/domain.util.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
export function getFileNameWithoutExtension(path: string): string {
|
||||
return basename(path, extname(path));
|
||||
}
|
||||
|
||||
export function getLivePhotoMotionFilename(stillName: string, motionName: string) {
|
||||
return getFileNameWithoutExtension(stillName) + extname(motionName);
|
||||
}
|
||||
|
||||
const KiB = Math.pow(1024, 1);
|
||||
const MiB = Math.pow(1024, 2);
|
||||
const GiB = Math.pow(1024, 3);
|
||||
const TiB = Math.pow(1024, 4);
|
||||
const PiB = Math.pow(1024, 5);
|
||||
|
||||
export const HumanReadableSize = { KiB, MiB, GiB, TiB, PiB };
|
||||
|
||||
export function asHumanReadable(bytes: number, precision = 1): string {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
|
||||
|
||||
let magnitude = 0;
|
||||
let remainder = bytes;
|
||||
while (remainder >= 1024) {
|
||||
if (magnitude + 1 < units.length) {
|
||||
magnitude++;
|
||||
remainder /= 1024;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
|
||||
}
|
||||
|
||||
export interface PaginationOptions {
|
||||
take: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
export interface PaginationResult<T> {
|
||||
items: T[];
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export type Paginated<T> = Promise<PaginationResult<T>>;
|
||||
|
||||
export async function* usePagination<T>(
|
||||
pageSize: number,
|
||||
getNextPage: (pagination: PaginationOptions) => Paginated<T>,
|
||||
) {
|
||||
let hasNextPage = true;
|
||||
|
||||
for (let skip = 0; hasNextPage; skip += pageSize) {
|
||||
const result = await getNextPage({ take: pageSize, skip });
|
||||
hasNextPage = result.hasNextPage;
|
||||
yield result.items;
|
||||
}
|
||||
}
|
||||
14
server/src/domain/facial-recognition/face.repository.ts
Normal file
14
server/src/domain/facial-recognition/face.repository.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { AssetFaceEntity } from '@app/infra/entities';
|
||||
|
||||
export const IFaceRepository = 'IFaceRepository';
|
||||
|
||||
export interface AssetFaceId {
|
||||
assetId: string;
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export interface IFaceRepository {
|
||||
getAll(): Promise<AssetFaceEntity[]>;
|
||||
getByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
|
||||
create(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity>;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
assetEntityStub,
|
||||
faceStub,
|
||||
newAssetRepositoryMock,
|
||||
newFaceRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMachineLearningRepositoryMock,
|
||||
newMediaRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newSearchRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
personStub,
|
||||
} from '@test';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IMediaRepository } from '../media';
|
||||
import { IPersonRepository } from '../person';
|
||||
import { ISearchRepository } from '../search';
|
||||
import { IMachineLearningRepository } from '../smart-info';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { IFaceRepository } from './face.repository';
|
||||
import { FacialRecognitionService } from './facial-recognition.services';
|
||||
|
||||
const croppedFace = Buffer.from('Cropped Face');
|
||||
|
||||
const face = {
|
||||
start: {
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
boundingBox: {
|
||||
x1: 5,
|
||||
y1: 5,
|
||||
x2: 505,
|
||||
y2: 505,
|
||||
},
|
||||
imageHeight: 1000,
|
||||
imageWidth: 1000,
|
||||
},
|
||||
middle: {
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
boundingBox: {
|
||||
x1: 100,
|
||||
y1: 100,
|
||||
x2: 200,
|
||||
y2: 200,
|
||||
},
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
embedding: [1, 2, 3, 4],
|
||||
score: 0.2,
|
||||
},
|
||||
end: {
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
boundingBox: {
|
||||
x1: 300,
|
||||
y1: 300,
|
||||
x2: 495,
|
||||
y2: 495,
|
||||
},
|
||||
imageHeight: 500,
|
||||
imageWidth: 500,
|
||||
},
|
||||
};
|
||||
|
||||
const faceSearch = {
|
||||
noMatch: {
|
||||
total: 0,
|
||||
count: 0,
|
||||
page: 1,
|
||||
items: [],
|
||||
distances: [],
|
||||
facets: [],
|
||||
},
|
||||
oneMatch: {
|
||||
total: 1,
|
||||
count: 1,
|
||||
page: 1,
|
||||
items: [faceStub.face1],
|
||||
distances: [0.1],
|
||||
facets: [],
|
||||
},
|
||||
oneRemoteMatch: {
|
||||
total: 1,
|
||||
count: 1,
|
||||
page: 1,
|
||||
items: [faceStub.face1],
|
||||
distances: [0.8],
|
||||
facets: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe(FacialRecognitionService.name, () => {
|
||||
let sut: FacialRecognitionService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let faceMock: jest.Mocked<IFaceRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let machineLearningMock: jest.Mocked<IMachineLearningRepository>;
|
||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let searchMock: jest.Mocked<ISearchRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
faceMock = newFaceRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
machineLearningMock = newMachineLearningRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
searchMock = newSearchRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
mediaMock.crop.mockResolvedValue(croppedFace);
|
||||
|
||||
sut = new FacialRecognitionService(
|
||||
assetMock,
|
||||
faceMock,
|
||||
jobMock,
|
||||
machineLearningMock,
|
||||
mediaMock,
|
||||
personMock,
|
||||
searchMock,
|
||||
storageMock,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleQueueRecognizeFaces', () => {
|
||||
it('should queue missing assets', async () => {
|
||||
assetMock.getWithout.mockResolvedValue({
|
||||
items: [assetEntityStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
await sut.handleQueueRecognizeFaces({});
|
||||
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.RECOGNIZE_FACES,
|
||||
data: { id: assetEntityStub.image.id },
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue all assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue({
|
||||
items: [assetEntityStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.deleteAll.mockResolvedValue(5);
|
||||
searchMock.deleteAllFaces.mockResolvedValue(100);
|
||||
|
||||
await sut.handleQueueRecognizeFaces({ force: true });
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.RECOGNIZE_FACES,
|
||||
data: { id: assetEntityStub.image.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRecognizeFaces', () => {
|
||||
it('should skip when no resize path', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
|
||||
await sut.handleRecognizeFaces({ id: assetEntityStub.noResizePath.id });
|
||||
expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle no results', async () => {
|
||||
machineLearningMock.detectFaces.mockResolvedValue([]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
|
||||
expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({
|
||||
imagePath: assetEntityStub.image.resizePath,
|
||||
});
|
||||
expect(faceMock.create).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should match existing people', async () => {
|
||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(faceMock.create).toHaveBeenCalledWith({
|
||||
personId: 'person-1',
|
||||
assetId: 'asset-id',
|
||||
embedding: [1, 2, 3, 4],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a new person', async () => {
|
||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
|
||||
personMock.create.mockResolvedValue(personStub.noName);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetEntityStub.image.ownerId });
|
||||
expect(faceMock.create).toHaveBeenCalledWith({
|
||||
personId: 'person-1',
|
||||
assetId: 'asset-id',
|
||||
embedding: [1, 2, 3, 4],
|
||||
});
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
||||
data: {
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
boundingBox: {
|
||||
x1: 100,
|
||||
y1: 100,
|
||||
x2: 200,
|
||||
y2: 200,
|
||||
},
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
score: 0.2,
|
||||
},
|
||||
},
|
||||
],
|
||||
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGenerateFaceThumbnail', () => {
|
||||
it('should skip an asset not found', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||
|
||||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip an asset without a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||
|
||||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
left: 95,
|
||||
top: 95,
|
||||
width: 110,
|
||||
height: 110,
|
||||
});
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
});
|
||||
expect(personMock.update).toHaveBeenCalledWith({
|
||||
id: 'person-1',
|
||||
thumbnailPath: 'upload/thumbs/user-id/person-1.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a thumbnail without going negative', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.start);
|
||||
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 510,
|
||||
height: 510,
|
||||
});
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a thumbnail without overflowing', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.end);
|
||||
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
left: 297,
|
||||
top: 297,
|
||||
width: 202,
|
||||
height: 202,
|
||||
});
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { IBaseJob, IEntityJob, IFaceThumbnailJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
|
||||
import { IPersonRepository } from '../person/person.repository';
|
||||
import { ISearchRepository } from '../search/search.repository';
|
||||
import { IMachineLearningRepository } from '../smart-info';
|
||||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||
import { AssetFaceId, IFaceRepository } from './face.repository';
|
||||
|
||||
export class FacialRecognitionService {
|
||||
private logger = new Logger(FacialRecognitionService.name);
|
||||
private storageCore = new StorageCore();
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IFaceRepository) private faceRepository: IFaceRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {}
|
||||
|
||||
async handleQueueRecognizeFaces({ force }: IBaseJob) {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
|
||||
});
|
||||
|
||||
if (force) {
|
||||
const people = await this.personRepository.deleteAll();
|
||||
const faces = await this.searchRepository.deleteAllFaces();
|
||||
this.logger.debug(`Deleted ${people} people and ${faces} faces`);
|
||||
}
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleRecognizeFaces({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset || !MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const faces = await this.machineLearning.detectFaces({ imagePath: asset.resizePath });
|
||||
|
||||
this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`);
|
||||
this.logger.verbose(faces.map((face) => ({ ...face, embedding: `float[${face.embedding.length}]` })));
|
||||
|
||||
for (const { embedding, ...rest } of faces) {
|
||||
const faceSearchResult = await this.searchRepository.searchFaces(embedding, { ownerId: asset.ownerId });
|
||||
|
||||
let personId: string | null = null;
|
||||
|
||||
// try to find a matching face and link to the associated person
|
||||
// The closer to 0, the better the match. Range is from 0 to 2
|
||||
if (faceSearchResult.total && faceSearchResult.distances[0] < 0.6) {
|
||||
this.logger.verbose(`Match face with distance ${faceSearchResult.distances[0]}`);
|
||||
personId = faceSearchResult.items[0].personId;
|
||||
}
|
||||
|
||||
if (!personId) {
|
||||
this.logger.debug('No matches, creating a new person.');
|
||||
const person = await this.personRepository.create({ ownerId: asset.ownerId });
|
||||
personId = person.id;
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
||||
data: { assetId: asset.id, personId, ...rest },
|
||||
});
|
||||
}
|
||||
|
||||
const faceId: AssetFaceId = { assetId: asset.id, personId };
|
||||
|
||||
await this.faceRepository.create({ ...faceId, embedding });
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) {
|
||||
const { assetId, personId, boundingBox, imageWidth, imageHeight } = data;
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([assetId]);
|
||||
if (!asset || !asset.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.verbose(`Cropping face for person: ${personId}`);
|
||||
|
||||
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||
const output = join(outputFolder, `${personId}.jpeg`);
|
||||
this.storageRepository.mkdirSync(outputFolder);
|
||||
|
||||
const { x1, y1, x2, y2 } = boundingBox;
|
||||
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
|
||||
const middleX = Math.round(x1 + halfWidth);
|
||||
const middleY = Math.round(y1 + halfHeight);
|
||||
|
||||
// zoom out 10%
|
||||
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
|
||||
|
||||
// get the longest distance from the center of the image without overflowing
|
||||
const newHalfSize = Math.min(
|
||||
middleX - Math.max(0, middleX - targetHalfSize),
|
||||
middleY - Math.max(0, middleY - targetHalfSize),
|
||||
Math.min(imageWidth - 1, middleX + targetHalfSize) - middleX,
|
||||
Math.min(imageHeight - 1, middleY + targetHalfSize) - middleY,
|
||||
);
|
||||
|
||||
const cropOptions: CropOptions = {
|
||||
left: middleX - newHalfSize,
|
||||
top: middleY - newHalfSize,
|
||||
width: newHalfSize * 2,
|
||||
height: newHalfSize * 2,
|
||||
};
|
||||
|
||||
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
|
||||
await this.mediaRepository.resize(croppedOutput, output, { size: FACE_THUMBNAIL_SIZE, format: 'jpeg' });
|
||||
await this.personRepository.update({ id: personId, thumbnailPath: output });
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
2
server/src/domain/facial-recognition/index.ts
Normal file
2
server/src/domain/facial-recognition/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './facial-recognition.services';
|
||||
export * from './face.repository';
|
||||
28
server/src/domain/index.ts
Normal file
28
server/src/domain/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export * from './access';
|
||||
export * from './album';
|
||||
export * from './api-key';
|
||||
export * from './asset';
|
||||
export * from './auth';
|
||||
export * from './communication';
|
||||
export * from './crypto';
|
||||
export * from './domain.config';
|
||||
export * from './domain.constant';
|
||||
export * from './domain.module';
|
||||
export * from './domain.util';
|
||||
export * from './facial-recognition';
|
||||
export * from './job';
|
||||
export * from './media';
|
||||
export * from './metadata';
|
||||
export * from './oauth';
|
||||
export * from './partner';
|
||||
export * from './person';
|
||||
export * from './search';
|
||||
export * from './server-info';
|
||||
export * from './shared-link';
|
||||
export * from './smart-info';
|
||||
export * from './storage';
|
||||
export * from './storage-template';
|
||||
export * from './system-config';
|
||||
export * from './tag';
|
||||
export * from './user';
|
||||
export * from './user-token';
|
||||
2
server/src/domain/job/dto/index.ts
Normal file
2
server/src/domain/job/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './job-command.dto';
|
||||
export * from './job-id.dto';
|
||||
14
server/src/domain/job/dto/job-command.dto.ts
Normal file
14
server/src/domain/job/dto/job-command.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { JobCommand } from '../job.constants';
|
||||
|
||||
export class JobCommandDto {
|
||||
@IsNotEmpty()
|
||||
@IsEnum(JobCommand)
|
||||
@ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' })
|
||||
command!: JobCommand;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
force!: boolean;
|
||||
}
|
||||
10
server/src/domain/job/dto/job-id.dto.ts
Normal file
10
server/src/domain/job/dto/job-id.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty } from 'class-validator';
|
||||
import { QueueName } from '../job.constants';
|
||||
|
||||
export class JobIdDto {
|
||||
@IsNotEmpty()
|
||||
@IsEnum(QueueName)
|
||||
@ApiProperty({ type: String, enum: QueueName, enumName: 'JobName' })
|
||||
jobId!: QueueName;
|
||||
}
|
||||
6
server/src/domain/job/index.ts
Normal file
6
server/src/domain/job/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './dto';
|
||||
export * from './job.constants';
|
||||
export * from './job.interface';
|
||||
export * from './job.repository';
|
||||
export * from './job.service';
|
||||
export * from './response-dto';
|
||||
137
server/src/domain/job/job.constants.ts
Normal file
137
server/src/domain/job/job.constants.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export enum QueueName {
|
||||
THUMBNAIL_GENERATION = 'thumbnailGeneration',
|
||||
METADATA_EXTRACTION = 'metadataExtraction',
|
||||
VIDEO_CONVERSION = 'videoConversion',
|
||||
OBJECT_TAGGING = 'objectTagging',
|
||||
RECOGNIZE_FACES = 'recognizeFaces',
|
||||
CLIP_ENCODING = 'clipEncoding',
|
||||
BACKGROUND_TASK = 'backgroundTask',
|
||||
STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration',
|
||||
SEARCH = 'search',
|
||||
SIDECAR = 'sidecar',
|
||||
}
|
||||
|
||||
export enum JobCommand {
|
||||
START = 'start',
|
||||
PAUSE = 'pause',
|
||||
RESUME = 'resume',
|
||||
EMPTY = 'empty',
|
||||
}
|
||||
|
||||
export enum JobName {
|
||||
// conversion
|
||||
QUEUE_VIDEO_CONVERSION = 'queue-video-conversion',
|
||||
VIDEO_CONVERSION = 'video-conversion',
|
||||
|
||||
// thumbnails
|
||||
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
|
||||
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
|
||||
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
|
||||
|
||||
// metadata
|
||||
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
|
||||
METADATA_EXTRACTION = 'metadata-extraction',
|
||||
|
||||
// user deletion
|
||||
USER_DELETION = 'user-deletion',
|
||||
USER_DELETE_CHECK = 'user-delete-check',
|
||||
|
||||
// storage template
|
||||
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
|
||||
STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single',
|
||||
SYSTEM_CONFIG_CHANGE = 'system-config-change',
|
||||
|
||||
// object tagging
|
||||
QUEUE_OBJECT_TAGGING = 'queue-object-tagging',
|
||||
CLASSIFY_IMAGE = 'classify-image',
|
||||
|
||||
// facial recognition
|
||||
QUEUE_RECOGNIZE_FACES = 'queue-recognize-faces',
|
||||
RECOGNIZE_FACES = 'recognize-faces',
|
||||
GENERATE_FACE_THUMBNAIL = 'generate-face-thumbnail',
|
||||
PERSON_CLEANUP = 'person-cleanup',
|
||||
|
||||
// cleanup
|
||||
DELETE_FILES = 'delete-files',
|
||||
|
||||
// search
|
||||
SEARCH_INDEX_ASSETS = 'search-index-assets',
|
||||
SEARCH_INDEX_ASSET = 'search-index-asset',
|
||||
SEARCH_INDEX_FACE = 'search-index-face',
|
||||
SEARCH_INDEX_FACES = 'search-index-faces',
|
||||
SEARCH_INDEX_ALBUMS = 'search-index-albums',
|
||||
SEARCH_INDEX_ALBUM = 'search-index-album',
|
||||
SEARCH_REMOVE_ALBUM = 'search-remove-album',
|
||||
SEARCH_REMOVE_ASSET = 'search-remove-asset',
|
||||
SEARCH_REMOVE_FACE = 'search-remove-face',
|
||||
|
||||
// clip
|
||||
QUEUE_ENCODE_CLIP = 'queue-clip-encode',
|
||||
ENCODE_CLIP = 'clip-encode',
|
||||
|
||||
// XMP sidecars
|
||||
QUEUE_SIDECAR = 'queue-sidecar',
|
||||
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
||||
SIDECAR_SYNC = 'sidecar-sync',
|
||||
}
|
||||
|
||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||
|
||||
export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
// misc
|
||||
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK,
|
||||
|
||||
// conversion
|
||||
[JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,
|
||||
[JobName.VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,
|
||||
|
||||
// thumbnails
|
||||
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
|
||||
[JobName.GENERATE_JPEG_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
||||
[JobName.GENERATE_WEBP_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
||||
|
||||
// metadata
|
||||
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||
[JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||
|
||||
// storage template
|
||||
[JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION,
|
||||
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: QueueName.STORAGE_TEMPLATE_MIGRATION,
|
||||
[JobName.SYSTEM_CONFIG_CHANGE]: QueueName.STORAGE_TEMPLATE_MIGRATION,
|
||||
|
||||
// object tagging
|
||||
[JobName.QUEUE_OBJECT_TAGGING]: QueueName.OBJECT_TAGGING,
|
||||
[JobName.CLASSIFY_IMAGE]: QueueName.OBJECT_TAGGING,
|
||||
|
||||
// facial recognition
|
||||
[JobName.QUEUE_RECOGNIZE_FACES]: QueueName.RECOGNIZE_FACES,
|
||||
[JobName.RECOGNIZE_FACES]: QueueName.RECOGNIZE_FACES,
|
||||
[JobName.GENERATE_FACE_THUMBNAIL]: QueueName.RECOGNIZE_FACES,
|
||||
|
||||
// clip
|
||||
[JobName.QUEUE_ENCODE_CLIP]: QueueName.CLIP_ENCODING,
|
||||
[JobName.ENCODE_CLIP]: QueueName.CLIP_ENCODING,
|
||||
|
||||
// search - albums
|
||||
[JobName.SEARCH_INDEX_ALBUMS]: QueueName.SEARCH,
|
||||
[JobName.SEARCH_INDEX_ALBUM]: QueueName.SEARCH,
|
||||
[JobName.SEARCH_REMOVE_ALBUM]: QueueName.SEARCH,
|
||||
|
||||
// search - assets
|
||||
[JobName.SEARCH_INDEX_ASSETS]: QueueName.SEARCH,
|
||||
[JobName.SEARCH_INDEX_ASSET]: QueueName.SEARCH,
|
||||
[JobName.SEARCH_REMOVE_ASSET]: QueueName.SEARCH,
|
||||
|
||||
// search - faces
|
||||
[JobName.SEARCH_INDEX_FACES]: QueueName.SEARCH,
|
||||
[JobName.SEARCH_INDEX_FACE]: QueueName.SEARCH,
|
||||
[JobName.SEARCH_REMOVE_FACE]: QueueName.SEARCH,
|
||||
|
||||
// XMP sidecars
|
||||
[JobName.QUEUE_SIDECAR]: QueueName.SIDECAR,
|
||||
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
|
||||
[JobName.SIDECAR_SYNC]: QueueName.SIDECAR,
|
||||
};
|
||||
31
server/src/domain/job/job.interface.ts
Normal file
31
server/src/domain/job/job.interface.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { BoundingBox } from '../smart-info';
|
||||
|
||||
export interface IBaseJob {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface IAssetFaceJob extends IBaseJob {
|
||||
assetId: string;
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export interface IFaceThumbnailJob extends IAssetFaceJob {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boundingBox: BoundingBox;
|
||||
assetId: string;
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export interface IEntityJob extends IBaseJob {
|
||||
id: string;
|
||||
source?: 'upload';
|
||||
}
|
||||
|
||||
export interface IBulkEntityJob extends IBaseJob {
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface IDeleteFilesJob extends IBaseJob {
|
||||
files: Array<string | null | undefined>;
|
||||
}
|
||||
96
server/src/domain/job/job.repository.ts
Normal file
96
server/src/domain/job/job.repository.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { JobName, QueueName } from './job.constants';
|
||||
import {
|
||||
IAssetFaceJob,
|
||||
IBaseJob,
|
||||
IBulkEntityJob,
|
||||
IDeleteFilesJob,
|
||||
IEntityJob,
|
||||
IFaceThumbnailJob,
|
||||
} from './job.interface';
|
||||
|
||||
export interface JobCounts {
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
waiting: number;
|
||||
paused: number;
|
||||
}
|
||||
|
||||
export interface QueueStatus {
|
||||
isActive: boolean;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
export type JobItem =
|
||||
// Transcoding
|
||||
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
|
||||
| { name: JobName.VIDEO_CONVERSION; data: IEntityJob }
|
||||
|
||||
// Thumbnails
|
||||
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
|
||||
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IEntityJob }
|
||||
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
|
||||
|
||||
// User Deletion
|
||||
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }
|
||||
| { name: JobName.USER_DELETION; data: IEntityJob }
|
||||
|
||||
// Storage Template
|
||||
| { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob }
|
||||
| { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob }
|
||||
| { name: JobName.SYSTEM_CONFIG_CHANGE; data?: IBaseJob }
|
||||
|
||||
// Metadata Extraction
|
||||
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
|
||||
| { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
|
||||
|
||||
// Sidecar Scanning
|
||||
| { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
|
||||
| { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob }
|
||||
| { name: JobName.SIDECAR_SYNC; data: IEntityJob }
|
||||
|
||||
// Object Tagging
|
||||
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
|
||||
| { name: JobName.CLASSIFY_IMAGE; data: IEntityJob }
|
||||
|
||||
// Recognize Faces
|
||||
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
|
||||
| { name: JobName.RECOGNIZE_FACES; data: IEntityJob }
|
||||
| { name: JobName.GENERATE_FACE_THUMBNAIL; data: IFaceThumbnailJob }
|
||||
|
||||
// Clip Embedding
|
||||
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
||||
| { name: JobName.ENCODE_CLIP; data: IEntityJob }
|
||||
|
||||
// Filesystem
|
||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||
|
||||
// Asset Deletion
|
||||
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
|
||||
|
||||
// Search
|
||||
| { name: JobName.SEARCH_INDEX_ASSETS; data?: IBaseJob }
|
||||
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_INDEX_FACES; data?: IBaseJob }
|
||||
| { name: JobName.SEARCH_INDEX_FACE; data: IAssetFaceJob }
|
||||
| { name: JobName.SEARCH_INDEX_ALBUMS; data?: IBaseJob }
|
||||
| { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob };
|
||||
|
||||
export type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
|
||||
|
||||
export const IJobRepository = 'IJobRepository';
|
||||
|
||||
export interface IJobRepository {
|
||||
addHandler(queueName: QueueName, concurrency: number, handler: (job: JobItem) => Promise<void>): void;
|
||||
setConcurrency(queueName: QueueName, concurrency: number): void;
|
||||
queue(item: JobItem): Promise<void>;
|
||||
pause(name: QueueName): Promise<void>;
|
||||
resume(name: QueueName): Promise<void>;
|
||||
empty(name: QueueName): Promise<void>;
|
||||
getQueueStatus(name: QueueName): Promise<QueueStatus>;
|
||||
getJobCounts(name: QueueName): Promise<JobCounts>;
|
||||
}
|
||||
303
server/src/domain/job/job.service.spec.ts
Normal file
303
server/src/domain/job/job.service.spec.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { SystemConfig } from '@app/infra/entities';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
asyncTick,
|
||||
newAssetRepositoryMock,
|
||||
newCommunicationRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
} from '@test';
|
||||
import { IAssetRepository } from '../asset';
|
||||
import { ICommunicationRepository } from '../communication';
|
||||
import { IJobRepository, JobCommand, JobHandler, JobItem, JobName, JobService, QueueName } from '.';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
|
||||
const makeMockHandlers = (success: boolean) => {
|
||||
const mock = jest.fn().mockResolvedValue(success);
|
||||
return Object.values(JobName).reduce((map, jobName) => ({ ...map, [jobName]: mock }), {}) as Record<
|
||||
JobName,
|
||||
JobHandler
|
||||
>;
|
||||
};
|
||||
|
||||
describe(JobService.name, () => {
|
||||
let sut: JobService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
sut = new JobService(assetMock, communicationMock, jobMock, configMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleNightlyJobs', () => {
|
||||
it('should run the scheduled jobs', async () => {
|
||||
await sut.handleNightlyJobs();
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.USER_DELETE_CHECK }],
|
||||
[{ name: JobName.PERSON_CLEANUP }],
|
||||
[{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllJobStatus', () => {
|
||||
it('should get all job statuses', async () => {
|
||||
jobMock.getJobCounts.mockResolvedValue({
|
||||
active: 1,
|
||||
completed: 1,
|
||||
failed: 1,
|
||||
delayed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
});
|
||||
jobMock.getQueueStatus.mockResolvedValue({
|
||||
isActive: true,
|
||||
isPaused: true,
|
||||
});
|
||||
|
||||
const expectedJobStatus = {
|
||||
jobCounts: {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
queueStatus: {
|
||||
isActive: true,
|
||||
isPaused: true,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(sut.getAllJobsStatus()).resolves.toEqual({
|
||||
[QueueName.BACKGROUND_TASK]: expectedJobStatus,
|
||||
[QueueName.CLIP_ENCODING]: expectedJobStatus,
|
||||
[QueueName.METADATA_EXTRACTION]: expectedJobStatus,
|
||||
[QueueName.OBJECT_TAGGING]: expectedJobStatus,
|
||||
[QueueName.SEARCH]: expectedJobStatus,
|
||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]: expectedJobStatus,
|
||||
[QueueName.THUMBNAIL_GENERATION]: expectedJobStatus,
|
||||
[QueueName.VIDEO_CONVERSION]: expectedJobStatus,
|
||||
[QueueName.RECOGNIZE_FACES]: expectedJobStatus,
|
||||
[QueueName.SIDECAR]: expectedJobStatus,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCommand', () => {
|
||||
it('should handle a pause command', async () => {
|
||||
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false });
|
||||
|
||||
expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
});
|
||||
|
||||
it('should handle a resume command', async () => {
|
||||
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.RESUME, force: false });
|
||||
|
||||
expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
});
|
||||
|
||||
it('should handle an empty command', async () => {
|
||||
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false });
|
||||
|
||||
expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
});
|
||||
|
||||
it('should not start a job that is already running', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false });
|
||||
|
||||
await expect(
|
||||
sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a start video conversion command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start storage template migration command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
|
||||
});
|
||||
|
||||
it('should handle a start object tagging command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.OBJECT_TAGGING, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start clip encoding command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.CLIP_ENCODING, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_ENCODE_CLIP, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start metadata extraction command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start sidecar command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start thumbnail generation command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start recognize faces command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.RECOGNIZE_FACES, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_RECOGNIZE_FACES, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should throw a bad request when an invalid queue is used', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await expect(
|
||||
sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerHandlers', () => {
|
||||
it('should register a handler for each queue', async () => {
|
||||
await sut.registerHandlers(makeMockHandlers(true));
|
||||
expect(configMock.load).toHaveBeenCalled();
|
||||
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
|
||||
});
|
||||
|
||||
it('should subscribe to config changes', async () => {
|
||||
await sut.registerHandlers(makeMockHandlers(false));
|
||||
|
||||
const configCore = new SystemConfigCore(newSystemConfigRepositoryMock());
|
||||
configCore.config$.next({
|
||||
job: {
|
||||
[QueueName.BACKGROUND_TASK]: { concurrency: 10 },
|
||||
[QueueName.CLIP_ENCODING]: { concurrency: 10 },
|
||||
[QueueName.METADATA_EXTRACTION]: { concurrency: 10 },
|
||||
[QueueName.OBJECT_TAGGING]: { concurrency: 10 },
|
||||
[QueueName.RECOGNIZE_FACES]: { concurrency: 10 },
|
||||
[QueueName.SEARCH]: { concurrency: 10 },
|
||||
[QueueName.SIDECAR]: { concurrency: 10 },
|
||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 10 },
|
||||
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
|
||||
[QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
|
||||
},
|
||||
} as SystemConfig);
|
||||
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.CLIP_ENCODING, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.OBJECT_TAGGING, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.RECOGNIZE_FACES, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.STORAGE_TEMPLATE_MIGRATION, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10);
|
||||
});
|
||||
|
||||
const tests: Array<{ item: JobItem; jobs: JobName[] }> = [
|
||||
{
|
||||
item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.METADATA_EXTRACTION],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.SIDECAR_DISCOVERY, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.METADATA_EXTRACTION],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, JobName.SEARCH_INDEX_ASSET],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } },
|
||||
jobs: [JobName.GENERATE_JPEG_THUMBNAIL],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } },
|
||||
jobs: [],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.GENERATE_WEBP_THUMBNAIL, JobName.CLASSIFY_IMAGE, JobName.ENCODE_CLIP, JobName.RECOGNIZE_FACES],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.SEARCH_INDEX_ASSET],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.ENCODE_CLIP, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.SEARCH_INDEX_ASSET],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.RECOGNIZE_FACES, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.SEARCH_INDEX_ASSET],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { item, jobs } of tests) {
|
||||
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
|
||||
assetMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
await sut.registerHandlers(makeMockHandlers(true));
|
||||
await jobMock.addHandler.mock.calls[0][2](item);
|
||||
await asyncTick(3);
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledTimes(jobs.length);
|
||||
for (const jobName of jobs) {
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() });
|
||||
}
|
||||
});
|
||||
|
||||
it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => {
|
||||
await sut.registerHandlers(makeMockHandlers(false));
|
||||
await jobMock.addHandler.mock.calls[0][2](item);
|
||||
await asyncTick(3);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
179
server/src/domain/job/job.service.ts
Normal file
179
server/src/domain/job/job.service.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { IAssetRepository, mapAsset } from '../asset';
|
||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
||||
import { assertMachineLearningEnabled } from '../domain.constant';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { JobCommandDto } from './dto';
|
||||
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||
import { IJobRepository, JobHandler, JobItem } from './job.repository';
|
||||
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class JobService {
|
||||
private logger = new Logger(JobService.name);
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
) {
|
||||
this.configCore = new SystemConfigCore(configRepository);
|
||||
}
|
||||
|
||||
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
||||
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
||||
|
||||
switch (dto.command) {
|
||||
case JobCommand.START:
|
||||
return this.start(queueName, dto);
|
||||
|
||||
case JobCommand.PAUSE:
|
||||
return this.jobRepository.pause(queueName);
|
||||
|
||||
case JobCommand.RESUME:
|
||||
return this.jobRepository.resume(queueName);
|
||||
|
||||
case JobCommand.EMPTY:
|
||||
return this.jobRepository.empty(queueName);
|
||||
}
|
||||
}
|
||||
|
||||
async getJobStatus(queueName: QueueName): Promise<JobStatusDto> {
|
||||
const [jobCounts, queueStatus] = await Promise.all([
|
||||
this.jobRepository.getJobCounts(queueName),
|
||||
this.jobRepository.getQueueStatus(queueName),
|
||||
]);
|
||||
|
||||
return { jobCounts, queueStatus };
|
||||
}
|
||||
|
||||
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
|
||||
const response = new AllJobStatusResponseDto();
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
response[queueName] = await this.getJobStatus(queueName);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async start(name: QueueName, { force }: JobCommandDto): Promise<void> {
|
||||
const { isActive } = await this.jobRepository.getQueueStatus(name);
|
||||
if (isActive) {
|
||||
throw new BadRequestException(`Job is already running`);
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case QueueName.VIDEO_CONVERSION:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } });
|
||||
|
||||
case QueueName.STORAGE_TEMPLATE_MIGRATION:
|
||||
return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
|
||||
|
||||
case QueueName.OBJECT_TAGGING:
|
||||
assertMachineLearningEnabled();
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } });
|
||||
|
||||
case QueueName.CLIP_ENCODING:
|
||||
assertMachineLearningEnabled();
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_ENCODE_CLIP, data: { force } });
|
||||
|
||||
case QueueName.METADATA_EXTRACTION:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } });
|
||||
|
||||
case QueueName.SIDECAR:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_SIDECAR, data: { force } });
|
||||
|
||||
case QueueName.THUMBNAIL_GENERATION:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });
|
||||
|
||||
case QueueName.RECOGNIZE_FACES:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_RECOGNIZE_FACES, data: { force } });
|
||||
|
||||
default:
|
||||
throw new BadRequestException(`Invalid job name: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async registerHandlers(jobHandlers: Record<JobName, JobHandler>) {
|
||||
const config = await this.configCore.getConfig();
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
const concurrency = config.job[queueName].concurrency;
|
||||
this.logger.debug(`Registering ${queueName} with a concurrency of ${concurrency}`);
|
||||
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
|
||||
const { name, data } = item;
|
||||
|
||||
try {
|
||||
const handler = jobHandlers[name];
|
||||
const success = await handler(data);
|
||||
if (success) {
|
||||
await this.onDone(item);
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Unable to run job handler: ${error}`, error?.stack, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.configCore.config$.subscribe((config) => {
|
||||
this.logger.log(`Updating queue concurrency settings`);
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
const concurrency = config.job[queueName].concurrency;
|
||||
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
|
||||
this.jobRepository.setConcurrency(queueName, concurrency);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleNightlyJobs() {
|
||||
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
|
||||
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
||||
await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue follow up jobs
|
||||
*/
|
||||
private async onDone(item: JobItem) {
|
||||
switch (item.name) {
|
||||
case JobName.SIDECAR_SYNC:
|
||||
case JobName.SIDECAR_DISCOVERY:
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: item.data });
|
||||
break;
|
||||
|
||||
case JobName.METADATA_EXTRACTION:
|
||||
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data });
|
||||
break;
|
||||
|
||||
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE:
|
||||
if (item.data.source === 'upload') {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: item.data });
|
||||
}
|
||||
break;
|
||||
|
||||
case JobName.GENERATE_JPEG_THUMBNAIL: {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([item.data.id]);
|
||||
if (asset) {
|
||||
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// In addition to the above jobs, all of these should queue `SEARCH_INDEX_ASSET`
|
||||
switch (item.name) {
|
||||
case JobName.CLASSIFY_IMAGE:
|
||||
case JobName.ENCODE_CLIP:
|
||||
case JobName.RECOGNIZE_FACES:
|
||||
case JobName.METADATA_EXTRACTION:
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [item.data.id] } });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { QueueName } from '../job.constants';
|
||||
|
||||
export class JobCountsDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
active!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
completed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
failed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
delayed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
waiting!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
paused!: number;
|
||||
}
|
||||
|
||||
export class QueueStatusDto {
|
||||
isActive!: boolean;
|
||||
isPaused!: boolean;
|
||||
}
|
||||
|
||||
export class JobStatusDto {
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
jobCounts!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: QueueStatusDto })
|
||||
queueStatus!: QueueStatusDto;
|
||||
}
|
||||
|
||||
export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto> {
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.THUMBNAIL_GENERATION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.METADATA_EXTRACTION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.VIDEO_CONVERSION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.OBJECT_TAGGING]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.CLIP_ENCODING]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.BACKGROUND_TASK]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.SEARCH]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.RECOGNIZE_FACES]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.SIDECAR]!: JobStatusDto;
|
||||
}
|
||||
1
server/src/domain/job/response-dto/index.ts
Normal file
1
server/src/domain/job/response-dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './all-job-status-response.dto';
|
||||
3
server/src/domain/media/index.ts
Normal file
3
server/src/domain/media/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './media.constant';
|
||||
export * from './media.repository';
|
||||
export * from './media.service';
|
||||
3
server/src/domain/media/media.constant.ts
Normal file
3
server/src/domain/media/media.constant.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const JPEG_THUMBNAIL_SIZE = 1440;
|
||||
export const WEBP_THUMBNAIL_SIZE = 250;
|
||||
export const FACE_THUMBNAIL_SIZE = 250;
|
||||
56
server/src/domain/media/media.repository.ts
Normal file
56
server/src/domain/media/media.repository.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export const IMediaRepository = 'IMediaRepository';
|
||||
|
||||
export interface ResizeOptions {
|
||||
size: number;
|
||||
format: 'webp' | 'jpeg';
|
||||
}
|
||||
|
||||
export interface VideoStreamInfo {
|
||||
height: number;
|
||||
width: number;
|
||||
rotation: number;
|
||||
codecName?: string;
|
||||
codecType?: string;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
export interface AudioStreamInfo {
|
||||
codecName?: string;
|
||||
codecType?: string;
|
||||
}
|
||||
|
||||
export interface VideoFormat {
|
||||
formatName?: string;
|
||||
formatLongName?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
format: VideoFormat;
|
||||
videoStreams: VideoStreamInfo[];
|
||||
audioStreams: AudioStreamInfo[];
|
||||
}
|
||||
|
||||
export interface CropOptions {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TranscodeOptions {
|
||||
outputOptions: string[];
|
||||
twoPass: boolean;
|
||||
}
|
||||
|
||||
export interface IMediaRepository {
|
||||
// image
|
||||
extractThumbnailFromExif(input: string, output: string): Promise<void>;
|
||||
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
|
||||
crop(input: string, options: CropOptions): Promise<Buffer>;
|
||||
|
||||
// video
|
||||
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;
|
||||
probe(input: string): Promise<VideoInfo>;
|
||||
transcode(input: string, output: string, options: TranscodeOptions): Promise<void>;
|
||||
}
|
||||
489
server/src/domain/media/media.service.spec.ts
Normal file
489
server/src/domain/media/media.service.spec.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { AssetType, SystemConfigKey } from '@app/infra/entities';
|
||||
import {
|
||||
assetEntityStub,
|
||||
newAssetRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMediaRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
probeStub,
|
||||
} from '@test';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IMediaRepository } from './media.repository';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
describe(MediaService.name, () => {
|
||||
let sut: MediaService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
sut = new MediaService(assetMock, jobMock, mediaMock, storageMock, configMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleQueueGenerateThumbnails', () => {
|
||||
it('should queue all assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue({
|
||||
items: [assetEntityStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||
data: { id: assetEntityStub.image.id },
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue all assets with missing thumbnails', async () => {
|
||||
assetMock.getWithout.mockResolvedValue({
|
||||
items: [assetEntityStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||
data: { id: assetEntityStub.image.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGenerateJpegThumbnail', () => {
|
||||
it('should generate a thumbnail for an image', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
size: 1440,
|
||||
format: 'jpeg',
|
||||
});
|
||||
expect(mediaMock.extractThumbnailFromExif).not.toHaveBeenCalled();
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
resizePath: 'upload/thumbs/user-id/asset-id.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a thumbnail for an image from exif', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
mediaMock.resize.mockRejectedValue(new Error('unsupported format'));
|
||||
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
size: 1440,
|
||||
format: 'jpeg',
|
||||
});
|
||||
expect(mediaMock.extractThumbnailFromExif).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/thumbs/user-id/asset-id.jpeg',
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
resizePath: 'upload/thumbs/user-id/asset-id.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a thumbnail for a video', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.video.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/thumbs/user-id/asset-id.jpeg',
|
||||
1440,
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
resizePath: 'upload/thumbs/user-id/asset-id.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should run successfully', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGenerateWebpThumbnail', () => {
|
||||
it('should skip thumbnail generate if resize path is missing', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
|
||||
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.noResizePath.id });
|
||||
expect(mediaMock.resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/uploads/user-id/thumbs/path.ext',
|
||||
'/uploads/user-id/thumbs/path.ext',
|
||||
{ format: 'webp', size: 250 },
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueVideoConversion', () => {
|
||||
it('should queue all video assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue({
|
||||
items: [assetEntityStub.video],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueVideoConversion({ force: true });
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO });
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.VIDEO_CONVERSION,
|
||||
data: { id: assetEntityStub.video.id },
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue all video assets without encoded videos', async () => {
|
||||
assetMock.getWithout.mockResolvedValue({
|
||||
items: [assetEntityStub.video],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueVideoConversion({});
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.VIDEO_CONVERSION,
|
||||
data: { id: assetEntityStub.video.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleVideoConversion', () => {
|
||||
beforeEach(() => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
});
|
||||
|
||||
it('should transcode the longest stream', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
|
||||
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
|
||||
expect(configMock.load).toHaveBeenCalled();
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalled();
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip a video without any streams', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip a video without any height', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.noHeight);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should transcode when set to all', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode when optimal and too big', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode with alternate scaling video is vertical', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=720:-2',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode when audio doesnt match target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode when container doesnt match target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transcode an invalid transcode value', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set max bitrate if above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
'-maxrate 4500k',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
|
||||
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-b:v 3104k',
|
||||
'-minrate 1552k',
|
||||
'-maxrate 4500k',
|
||||
],
|
||||
twoPass: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure preset for vp9', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec vp9',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-cpu-used 5',
|
||||
'-row-mt 1',
|
||||
'-threads 2',
|
||||
'-crf 23',
|
||||
'-b:v 0',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure threads if above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec vp9',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-cpu-used 5',
|
||||
'-row-mt 1',
|
||||
'-threads 2',
|
||||
'-crf 23',
|
||||
'-b:v 0',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-threads 2',
|
||||
'-x264-params "pools=none"',
|
||||
'-x264-params "frame-threads=2"',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
290
server/src/domain/media/media.service.ts
Normal file
290
server/src/domain/media/media.service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
|
||||
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private logger = new Logger(MediaService.name);
|
||||
private storageCore = new StorageCore();
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ISystemConfigRepository) systemConfig: ISystemConfigRepository,
|
||||
) {
|
||||
this.configCore = new SystemConfigCore(systemConfig);
|
||||
}
|
||||
|
||||
async handleQueueGenerateThumbnails(job: IBaseJob) {
|
||||
const { force } = job;
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleGenerateJpegThumbnail({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||
this.storageRepository.mkdirSync(resizePath);
|
||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
try {
|
||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||
size: JPEG_THUMBNAIL_SIZE,
|
||||
format: 'jpeg',
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to generate jpeg thumbnail using sharp, trying with exiftool-vendored (asset=${asset.id})`,
|
||||
);
|
||||
await this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
this.logger.log('Start Generating Video Thumbnail');
|
||||
await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath, JPEG_THUMBNAIL_SIZE);
|
||||
this.logger.log(`Generating Video Thumbnail Success ${asset.id}`);
|
||||
}
|
||||
|
||||
await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleGenerateWepbThumbnail({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset || !asset.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||
|
||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
|
||||
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleQueueVideoConversion(job: IBaseJob) {
|
||||
const { force } = job;
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleVideoConversion({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const input = asset.originalPath;
|
||||
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
|
||||
const output = join(outputFolder, `${asset.id}.mp4`);
|
||||
this.storageRepository.mkdirSync(outputFolder);
|
||||
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
|
||||
const mainVideoStream = this.getMainVideoStream(videoStreams);
|
||||
const mainAudioStream = this.getMainAudioStream(audioStreams);
|
||||
const containerExtension = format.formatName;
|
||||
if (!mainVideoStream || !mainAudioStream || !containerExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { ffmpeg: config } = await this.configCore.getConfig();
|
||||
|
||||
const required = this.isTranscodeRequired(asset, mainVideoStream, mainAudioStream, containerExtension, config);
|
||||
if (!required) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
|
||||
const twoPass = this.eligibleForTwoPass(config);
|
||||
|
||||
this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`);
|
||||
await this.mediaRepository.transcode(input, output, { outputOptions, twoPass });
|
||||
|
||||
this.logger.log(`Encoding success ${asset.id}`);
|
||||
|
||||
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getMainVideoStream(streams: VideoStreamInfo[]): VideoStreamInfo | null {
|
||||
return streams.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
|
||||
}
|
||||
|
||||
private getMainAudioStream(streams: AudioStreamInfo[]): AudioStreamInfo | null {
|
||||
return streams[0];
|
||||
}
|
||||
|
||||
private isTranscodeRequired(
|
||||
asset: AssetEntity,
|
||||
videoStream: VideoStreamInfo,
|
||||
audioStream: AudioStreamInfo,
|
||||
containerExtension: string,
|
||||
ffmpegConfig: SystemConfigFFmpegDto,
|
||||
): boolean {
|
||||
if (!videoStream.height || !videoStream.width) {
|
||||
this.logger.error('Skipping transcode, height or width undefined for video stream');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isTargetVideoCodec = videoStream.codecName === ffmpegConfig.targetVideoCodec;
|
||||
const isTargetAudioCodec = audioStream.codecName === ffmpegConfig.targetAudioCodec;
|
||||
const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension);
|
||||
|
||||
this.logger.verbose(
|
||||
`${asset.id}: AudioCodecName ${audioStream.codecName}, AudioStreamCodecType ${audioStream.codecType}, containerExtension ${containerExtension}`,
|
||||
);
|
||||
|
||||
const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer;
|
||||
|
||||
const targetResolution = Number.parseInt(ffmpegConfig.targetResolution);
|
||||
const isLargerThanTargetResolution = Math.min(videoStream.height, videoStream.width) > targetResolution;
|
||||
|
||||
switch (ffmpegConfig.transcode) {
|
||||
case TranscodePreset.DISABLED:
|
||||
return false;
|
||||
|
||||
case TranscodePreset.ALL:
|
||||
return true;
|
||||
|
||||
case TranscodePreset.REQUIRED:
|
||||
return !allTargetsMatching;
|
||||
|
||||
case TranscodePreset.OPTIMAL:
|
||||
return !allTargetsMatching || isLargerThanTargetResolution;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) {
|
||||
const options = [
|
||||
`-vcodec ${ffmpeg.targetVideoCodec}`,
|
||||
`-acodec ${ffmpeg.targetAudioCodec}`,
|
||||
// Makes a second pass moving the moov atom to the beginning of
|
||||
// the file for improved playback speed.
|
||||
`-movflags faststart`,
|
||||
];
|
||||
|
||||
// video dimensions
|
||||
const videoIsRotated = Math.abs(stream.rotation) === 90;
|
||||
const targetResolution = Number.parseInt(ffmpeg.targetResolution);
|
||||
const isVideoVertical = stream.height > stream.width || videoIsRotated;
|
||||
const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`;
|
||||
const shouldScale = Math.min(stream.height, stream.width) > targetResolution;
|
||||
|
||||
// video codec
|
||||
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
|
||||
const isH264 = ffmpeg.targetVideoCodec === 'h264';
|
||||
const isH265 = ffmpeg.targetVideoCodec === 'hevc';
|
||||
|
||||
// transcode efficiency
|
||||
const limitThreads = ffmpeg.threads > 0;
|
||||
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
|
||||
const constrainMaximumBitrate = maxBitrateValue > 0;
|
||||
const bitrateUnit = ffmpeg.maxBitrate.trim().substring(maxBitrateValue.toString().length); // use inputted unit if provided
|
||||
|
||||
if (shouldScale) {
|
||||
options.push(`-vf scale=${scaling}`);
|
||||
}
|
||||
|
||||
if (isH264 || isH265) {
|
||||
options.push(`-preset ${ffmpeg.preset}`);
|
||||
}
|
||||
|
||||
if (isVP9) {
|
||||
// vp9 doesn't have presets, but does have a similar setting -cpu-used, from 0-5, 0 being the slowest
|
||||
const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
||||
const speed = Math.min(presets.indexOf(ffmpeg.preset), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
|
||||
if (speed >= 0) {
|
||||
options.push(`-cpu-used ${speed}`);
|
||||
}
|
||||
options.push('-row-mt 1'); // better multithreading
|
||||
}
|
||||
|
||||
if (limitThreads) {
|
||||
options.push(`-threads ${ffmpeg.threads}`);
|
||||
|
||||
// x264 and x265 handle threads differently than one might expect
|
||||
// https://x265.readthedocs.io/en/latest/cli.html#cmdoption-pools
|
||||
if (isH264 || isH265) {
|
||||
options.push(`-${isH265 ? 'x265' : 'x264'}-params "pools=none"`);
|
||||
options.push(`-${isH265 ? 'x265' : 'x264'}-params "frame-threads=${ffmpeg.threads}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// two-pass mode for x264/x265 uses bitrate ranges, so it requires a max bitrate from which to derive a target and min bitrate
|
||||
if (constrainMaximumBitrate && ffmpeg.twoPass) {
|
||||
const targetBitrateValue = Math.ceil(maxBitrateValue / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod
|
||||
const minBitrateValue = targetBitrateValue / 2;
|
||||
|
||||
options.push(`-b:v ${targetBitrateValue}${bitrateUnit}`);
|
||||
options.push(`-minrate ${minBitrateValue}${bitrateUnit}`);
|
||||
options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`);
|
||||
} else if (constrainMaximumBitrate || isVP9) {
|
||||
// for vp9, these flags work for both one-pass and two-pass
|
||||
options.push(`-crf ${ffmpeg.crf}`);
|
||||
options.push(`${isVP9 ? '-b:v' : '-maxrate'} ${maxBitrateValue}${bitrateUnit}`);
|
||||
} else {
|
||||
options.push(`-crf ${ffmpeg.crf}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private eligibleForTwoPass(ffmpeg: SystemConfigFFmpegDto) {
|
||||
if (!ffmpeg.twoPass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
|
||||
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
|
||||
const constrainMaximumBitrate = maxBitrateValue > 0;
|
||||
|
||||
return constrainMaximumBitrate || isVP9;
|
||||
}
|
||||
}
|
||||
18
server/src/domain/metadata/geocoding.repository.ts
Normal file
18
server/src/domain/metadata/geocoding.repository.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const IGeocodingRepository = 'IGeocodingRepository';
|
||||
|
||||
export interface GeoPoint {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface ReverseGeocodeResult {
|
||||
country: string | null;
|
||||
state: string | null;
|
||||
city: string | null;
|
||||
}
|
||||
|
||||
export interface IGeocodingRepository {
|
||||
init(): Promise<void>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
||||
deleteCache(): Promise<void>;
|
||||
}
|
||||
2
server/src/domain/metadata/index.ts
Normal file
2
server/src/domain/metadata/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './geocoding.repository';
|
||||
export * from './metadata.service';
|
||||
104
server/src/domain/metadata/metadata.service.spec.ts
Normal file
104
server/src/domain/metadata/metadata.service.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { constants } from 'fs/promises';
|
||||
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock, newStorageRepositoryMock } from '@test';
|
||||
import { IAssetRepository, WithoutProperty, WithProperty } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { MetadataService } from './metadata.service';
|
||||
|
||||
describe(MetadataService.name, () => {
|
||||
let sut: MetadataService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
sut = new MetadataService(assetMock, jobMock, storageMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleQueueSidecar', () => {
|
||||
it('should queue assets with sidecar files', async () => {
|
||||
assetMock.getWith.mockResolvedValue({ items: [assetEntityStub.sidecar], hasNextPage: false });
|
||||
|
||||
await sut.handleQueueSidecar({ force: true });
|
||||
|
||||
expect(assetMock.getWith).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithProperty.SIDECAR);
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SIDECAR_SYNC,
|
||||
data: { id: assetEntityStub.sidecar.id },
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue assets without sidecar files', async () => {
|
||||
assetMock.getWithout.mockResolvedValue({ items: [assetEntityStub.image], hasNextPage: false });
|
||||
|
||||
await sut.handleQueueSidecar({ force: false });
|
||||
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR);
|
||||
expect(assetMock.getWith).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SIDECAR_DISCOVERY,
|
||||
data: { id: assetEntityStub.image.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarSync', () => {
|
||||
it('should not error', async () => {
|
||||
await sut.handleSidecarSync();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarDiscovery', () => {
|
||||
it('should skip hidden assets', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.livePhotoMotionAsset]);
|
||||
await sut.handleSidecarDiscovery({ id: assetEntityStub.livePhotoMotionAsset.id });
|
||||
expect(storageMock.checkFileExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip assets with a sidecar path', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.sidecar]);
|
||||
await sut.handleSidecarDiscovery({ id: assetEntityStub.sidecar.id });
|
||||
expect(storageMock.checkFileExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when a sidecar is not found ', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
storageMock.checkFileExists.mockResolvedValue(false);
|
||||
await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
|
||||
expect(assetMock.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a image asset when a sidecar is found', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
||||
storageMock.checkFileExists.mockResolvedValue(true);
|
||||
await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
|
||||
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
sidecarPath: '/original/path.ext.xmp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update a video asset when a sidecar is found', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
assetMock.save.mockResolvedValue(assetEntityStub.video);
|
||||
storageMock.checkFileExists.mockResolvedValue(true);
|
||||
await sut.handleSidecarDiscovery({ id: assetEntityStub.video.id });
|
||||
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
sidecarPath: '/original/path.ext.xmp',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
54
server/src/domain/metadata/metadata.service.ts
Normal file
54
server/src/domain/metadata/metadata.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { constants } from 'fs/promises';
|
||||
import { IAssetRepository, WithoutProperty, WithProperty } from '../asset';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||
import { IStorageRepository } from '../storage';
|
||||
|
||||
export class MetadataService {
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {}
|
||||
|
||||
async handleQueueSidecar(job: IBaseJob) {
|
||||
const { force } = job;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getWith(pagination, WithProperty.SIDECAR)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY;
|
||||
await this.jobRepository.queue({ name, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleSidecarSync() {
|
||||
// TODO: optimize to only queue assets with recent xmp changes
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleSidecarDiscovery({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset || !asset.isVisible || asset.sidecarPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sidecarPath = `${asset.originalPath}.xmp`;
|
||||
const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.W_OK);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.assetRepository.save({ id: asset.id, sidecarPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
2
server/src/domain/oauth/dto/index.ts
Normal file
2
server/src/domain/oauth/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './oauth-auth-code.dto';
|
||||
export * from './oauth-config.dto';
|
||||
9
server/src/domain/oauth/dto/oauth-auth-code.dto.ts
Normal file
9
server/src/domain/oauth/dto/oauth-auth-code.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
url!: string;
|
||||
}
|
||||
9
server/src/domain/oauth/dto/oauth-config.dto.ts
Normal file
9
server/src/domain/oauth/dto/oauth-config.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class OAuthConfigDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
redirectUri!: string;
|
||||
}
|
||||
4
server/src/domain/oauth/index.ts
Normal file
4
server/src/domain/oauth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './dto';
|
||||
export * from './oauth.constants';
|
||||
export * from './oauth.service';
|
||||
export * from './response-dto';
|
||||
1
server/src/domain/oauth/oauth.constants.ts
Normal file
1
server/src/domain/oauth/oauth.constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MOBILE_REDIRECT = 'app.immich:/';
|
||||
107
server/src/domain/oauth/oauth.core.ts
Normal file
107
server/src/domain/oauth/oauth.core.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { SystemConfig } from '@app/infra/entities';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { OAuthConfigDto } from './dto';
|
||||
import { MOBILE_REDIRECT } from './oauth.constants';
|
||||
import { OAuthConfigResponseDto } from './response-dto';
|
||||
|
||||
type OAuthProfile = UserinfoResponse & {
|
||||
email: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OAuthCore {
|
||||
private readonly logger = new Logger(OAuthCore.name);
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(configRepository: ISystemConfigRepository, private config: SystemConfig) {
|
||||
this.configCore = new SystemConfigCore(configRepository);
|
||||
|
||||
custom.setHttpOptionsDefaults({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
this.configCore.config$.subscribe((config) => (this.config = config));
|
||||
}
|
||||
|
||||
async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
||||
const response = {
|
||||
enabled: this.config.oauth.enabled,
|
||||
passwordLoginEnabled: this.config.passwordLogin.enabled,
|
||||
};
|
||||
|
||||
if (!response.enabled) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const { scope, buttonText, autoLaunch } = this.config.oauth;
|
||||
const url = (await this.getClient()).authorizationUrl({
|
||||
redirect_uri: this.normalize(dto.redirectUri),
|
||||
scope,
|
||||
state: generators.state(),
|
||||
});
|
||||
|
||||
return { ...response, buttonText, url, autoLaunch };
|
||||
}
|
||||
|
||||
async callback(url: string): Promise<OAuthProfile> {
|
||||
const redirectUri = this.normalize(url.split('?')[0]);
|
||||
const client = await this.getClient();
|
||||
const params = client.callbackParams(url);
|
||||
const tokens = await client.callback(redirectUri, params, { state: params.state });
|
||||
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
||||
}
|
||||
|
||||
isAutoRegisterEnabled() {
|
||||
return this.config.oauth.autoRegister;
|
||||
}
|
||||
|
||||
asUser(profile: OAuthProfile) {
|
||||
return {
|
||||
firstName: profile.given_name || '',
|
||||
lastName: profile.family_name || '',
|
||||
email: profile.email,
|
||||
oauthId: profile.sub,
|
||||
};
|
||||
}
|
||||
|
||||
async getLogoutEndpoint(): Promise<string | null> {
|
||||
if (!this.config.oauth.enabled) {
|
||||
return null;
|
||||
}
|
||||
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
|
||||
}
|
||||
|
||||
private async getClient() {
|
||||
const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
|
||||
|
||||
if (!enabled) {
|
||||
throw new BadRequestException('OAuth2 is not enabled');
|
||||
}
|
||||
|
||||
const metadata: ClientMetadata = {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
response_types: ['code'],
|
||||
};
|
||||
|
||||
const issuer = await Issuer.discover(issuerUrl);
|
||||
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
|
||||
if (algorithms[0] === 'HS256') {
|
||||
metadata.id_token_signed_response_alg = algorithms[0];
|
||||
}
|
||||
|
||||
return new issuer.Client(metadata);
|
||||
}
|
||||
|
||||
private normalize(redirectUri: string) {
|
||||
const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
|
||||
const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
|
||||
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
|
||||
return mobileRedirectUri;
|
||||
}
|
||||
return redirectUri;
|
||||
}
|
||||
}
|
||||
217
server/src/domain/oauth/oauth.service.spec.ts
Normal file
217
server/src/domain/oauth/oauth.service.spec.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import {
|
||||
authStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
newUserTokenRepositoryMock,
|
||||
systemConfigStub,
|
||||
userEntityStub,
|
||||
userTokenEntityStub,
|
||||
} from '@test';
|
||||
import { OAuthService } from '.';
|
||||
import { LoginDetails } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IUserTokenRepository } from '../user-token';
|
||||
|
||||
const email = 'user@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
const loginDetails: LoginDetails = {
|
||||
isSecure: true,
|
||||
clientIp: '127.0.0.1',
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
};
|
||||
|
||||
describe('OAuthService', () => {
|
||||
let sut: OAuthService;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let userTokenMock: jest.Mocked<IUserTokenRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => OAuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
|
||||
|
||||
jest.spyOn(generators, 'state').mockReturnValue('state');
|
||||
jest.spyOn(Issuer, 'discover').mockResolvedValue({
|
||||
id_token_signing_alg_values_supported: ['HS256'],
|
||||
Client: jest.fn().mockResolvedValue({
|
||||
issuer: {
|
||||
metadata: {
|
||||
end_session_endpoint: 'http://end-session-endpoint',
|
||||
},
|
||||
},
|
||||
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
|
||||
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
|
||||
callback: callbackMock,
|
||||
userinfo: jest.fn().mockResolvedValue({ sub, email }),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
|
||||
create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config);
|
||||
|
||||
sut = create(systemConfigStub.disabled);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getMobileRedirect', () => {
|
||||
it('should pass along the query params', () => {
|
||||
expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
|
||||
});
|
||||
|
||||
it('should work if called without query params', () => {
|
||||
expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConfig', () => {
|
||||
it('should work when oauth is not configured', async () => {
|
||||
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
|
||||
enabled: false,
|
||||
passwordLoginEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate the config', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
|
||||
enabled: true,
|
||||
buttonText: 'OAuth',
|
||||
url: 'http://authorization-url',
|
||||
autoLaunch: false,
|
||||
passwordLoginEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should throw an error if OAuth is not enabled', async () => {
|
||||
await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should not allow auto registering', async () => {
|
||||
sut = create(systemConfigStub.noAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
sut = create(systemConfigStub.noAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
|
||||
userMock.create.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||
expect(userMock.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use the mobile redirect override', async () => {
|
||||
sut = create(systemConfigStub.override);
|
||||
|
||||
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
|
||||
|
||||
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
|
||||
});
|
||||
|
||||
it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
|
||||
sut = create(systemConfigStub.override);
|
||||
|
||||
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await sut.login({ url: `app.immich:///?code=abc123` }, loginDetails);
|
||||
|
||||
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('should link an account', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
|
||||
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
|
||||
|
||||
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
|
||||
|
||||
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlink', () => {
|
||||
it('should unlink an account', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
|
||||
await sut.unlink(authStub.user1);
|
||||
|
||||
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogoutEndpoint', () => {
|
||||
it('should return null if OAuth is not configured', async () => {
|
||||
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should get the session endpoint from the discovery document', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
|
||||
});
|
||||
});
|
||||
});
|
||||
92
server/src/domain/oauth/oauth.service.ts
Normal file
92
server/src/domain/oauth/oauth.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { SystemConfig } from '@app/infra/entities';
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
|
||||
import { AuthCore, LoginDetails } from '../auth/auth.core';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
import { IUserTokenRepository } from '../user-token';
|
||||
import { OAuthCallbackDto, OAuthConfigDto } from './dto';
|
||||
import { MOBILE_REDIRECT } from './oauth.constants';
|
||||
import { OAuthCore } from './oauth.core';
|
||||
import { OAuthConfigResponseDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
|
||||
) {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
}
|
||||
|
||||
getMobileRedirect(url: string) {
|
||||
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
|
||||
}
|
||||
|
||||
generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
||||
return this.oauthCore.generateConfig(dto);
|
||||
}
|
||||
|
||||
async login(
|
||||
dto: OAuthCallbackDto,
|
||||
loginDetails: LoginDetails,
|
||||
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||
const profile = await this.oauthCore.callback(dto.url);
|
||||
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
let user = await this.userCore.getByOAuthId(profile.sub);
|
||||
|
||||
// link existing user
|
||||
if (!user) {
|
||||
const emailUser = await this.userCore.getByEmail(profile.email);
|
||||
if (emailUser) {
|
||||
user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
|
||||
}
|
||||
}
|
||||
|
||||
// register new user
|
||||
if (!user) {
|
||||
if (!this.oauthCore.isAutoRegisterEnabled()) {
|
||||
this.logger.warn(
|
||||
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
|
||||
);
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
|
||||
user = await this.userCore.createUser(this.oauthCore.asUser(profile));
|
||||
}
|
||||
|
||||
return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
|
||||
}
|
||||
|
||||
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
||||
const { sub: oauthId } = await this.oauthCore.callback(dto.url);
|
||||
const duplicate = await this.userCore.getByOAuthId(oauthId);
|
||||
if (duplicate && duplicate.id !== user.id) {
|
||||
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
return this.userCore.updateUser(user, user.id, { oauthId });
|
||||
}
|
||||
|
||||
public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
|
||||
return this.userCore.updateUser(user, user.id, { oauthId: '' });
|
||||
}
|
||||
|
||||
public async getLogoutEndpoint(): Promise<string | null> {
|
||||
return this.oauthCore.getLogoutEndpoint();
|
||||
}
|
||||
}
|
||||
1
server/src/domain/oauth/response-dto/index.ts
Normal file
1
server/src/domain/oauth/response-dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './oauth-config-response.dto';
|
||||
@@ -0,0 +1,7 @@
|
||||
export class OAuthConfigResponseDto {
|
||||
enabled!: boolean;
|
||||
passwordLoginEnabled!: boolean;
|
||||
url?: string;
|
||||
buttonText?: string;
|
||||
autoLaunch?: boolean;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user