refactor(server)*: tsconfigs (#2689)

* refactor(server): tsconfigs

* chore: dummy commit

* fix: start.sh

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

View File

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export * from './access.repository';

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

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

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

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

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

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

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

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

View File

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,2 @@
export * from './asset-ids.dto';
export * from './map-marker.dto';

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

View File

@@ -0,0 +1,4 @@
export * from './asset.repository';
export * from './asset.service';
export * from './dto';
export * from './response-dto';

View File

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

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

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

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

View File

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

View File

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

View 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',
}

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
export * from './auth.constant';
export * from './auth.core';
export * from './auth.service';
export * from './dto';
export * from './response-dto';

View File

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

View File

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

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

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

View File

@@ -0,0 +1,4 @@
export class LogoutResponseDto {
successful!: boolean;
redirectUri!: string;
}

View File

@@ -0,0 +1,3 @@
export class ValidateAccessTokenResponseDto {
authStatus!: boolean;
}

View File

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

View File

@@ -0,0 +1 @@
export * from './communication.repository';

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

View File

@@ -0,0 +1 @@
export * from './crypto.repository';

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export * from './facial-recognition.services';
export * from './face.repository';

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

View File

@@ -0,0 +1,2 @@
export * from './job-command.dto';
export * from './job-id.dto';

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export * from './media.constant';
export * from './media.repository';
export * from './media.service';

View File

@@ -0,0 +1,3 @@
export const JPEG_THUMBNAIL_SIZE = 1440;
export const WEBP_THUMBNAIL_SIZE = 250;
export const FACE_THUMBNAIL_SIZE = 250;

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

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

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

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

View File

@@ -0,0 +1,2 @@
export * from './geocoding.repository';
export * from './metadata.service';

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

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

View File

@@ -0,0 +1,2 @@
export * from './oauth-auth-code.dto';
export * from './oauth-config.dto';

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthCallbackDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
url!: string;
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthConfigDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
redirectUri!: string;
}

View File

@@ -0,0 +1,4 @@
export * from './dto';
export * from './oauth.constants';
export * from './oauth.service';
export * from './response-dto';

View File

@@ -0,0 +1 @@
export const MOBILE_REDIRECT = 'app.immich:/';

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

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

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

View File

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

View File

@@ -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