Compare commits

..

1 Commits

Author SHA1 Message Date
bo0tzz c24830acbf fix: do not cancel running docs builds 2024-10-02 10:24:20 +02:00
150 changed files with 2221 additions and 1872 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ on:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: false
jobs: jobs:
pre-job: pre-job:
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.23", "version": "2.2.22",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.23", "version": "2.2.22",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.117.0", "version": "1.116.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.23", "version": "2.2.22",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
-2
View File
@@ -30,8 +30,6 @@ You can organize these as one parent with seven child datasets, for example `mnt
:::info Permissions :::info Permissions
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions. The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
The **library** dataset must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **uploads** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command.
::: :::
## Installing the Immich Application ## Installing the Immich Application
+1 -1
View File
@@ -201,7 +201,7 @@ const config = {
darkTheme: prism.themes.dracula, darkTheme: prism.themes.dracula,
additionalLanguages: ['sql', 'diff', 'bash', 'powershell', 'nginx'], additionalLanguages: ['sql', 'diff', 'bash', 'powershell', 'nginx'],
}, },
image: 'img/feature-panel.png', image: 'overview/img/feature-panel.png',
}), }),
}; };
-4
View File
@@ -1,8 +1,4 @@
[ [
{
"label": "v1.117.0",
"url": "https://v1.117.0.archive.immich.app"
},
{ {
"label": "v1.116.2", "label": "v1.116.2",
"url": "https://v1.116.2.archive.immich.app" "url": "https://v1.116.2.archive.immich.app"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 KiB

+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.117.0", "version": "1.116.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.117.0", "version": "1.116.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.23", "version": "2.2.22",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -92,7 +92,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.117.0", "version": "1.116.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.117.0", "version": "1.116.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.117.0" version = "1.116.2"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 162, "android.injected.version.code" => 161,
"android.injected.version.name" => "1.117.0", "android.injected.version.name" => "1.116.2",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.117.0" version_number: "1.116.2"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
+1 -1
View File
@@ -507,7 +507,7 @@ class SyncService {
List<Album> onDevice, [ List<Album> onDevice, [
Set<String>? excludedAssets, Set<String>? excludedAssets,
]) async { ]) async {
onDevice.sort((a, b) => a.localId!.compareTo(b.localId!)); onDevice.sort((a, b) => a.id.compareTo(b.id));
final inDb = final inDb =
await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId); await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId);
final List<Asset> deleteCandidates = []; final List<Asset> deleteCandidates = [];
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.117.0 - API version: 1.116.2
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.117.0+162 version: 1.116.2+161
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
+1 -1
View File
@@ -7436,7 +7436,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.117.0", "version": "1.116.2",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.117.0", "version": "1.116.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.117.0", "version": "1.116.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.117.0", "version": "1.116.2",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.117.0 * 1.116.2
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.117.0", "version": "1.116.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.117.0", "version": "1.116.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^10.0.1", "@nestjs/bullmq": "^10.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.117.0", "version": "1.116.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+1 -1
View File
@@ -7,7 +7,7 @@ import { useSwagger } from 'src/utils/misc';
const sync = async () => { const sync = async () => {
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { preview: true }); const app = await NestFactory.create<NestExpressApplication>(ApiModule, { preview: true });
useSwagger(app, { write: true }); useSwagger(app, true);
await app.close(); await app.close();
}; };
+1 -5
View File
@@ -12,7 +12,6 @@ import {
Colorspace, Colorspace,
CQMode, CQMode,
ImageFormat, ImageFormat,
ImmichEnvironment,
LogLevel, LogLevel,
ToneMapping, ToneMapping,
TranscodeHWAccel, TranscodeHWAccel,
@@ -323,10 +322,7 @@ export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env', envFilePath: '.env',
isGlobal: true, isGlobal: true,
validationSchema: Joi.object({ validationSchema: Joi.object({
IMMICH_ENV: Joi.string() IMMICH_ENV: Joi.string().optional().valid('development', 'testing', 'production').default('production'),
.optional()
.valid(...Object.values(ImmichEnvironment))
.default(ImmichEnvironment.PRODUCTION),
IMMICH_LOG_LEVEL: Joi.string() IMMICH_LOG_LEVEL: Joi.string()
.optional() .optional()
.valid(...Object.values(LogLevel)), .valid(...Object.values(LogLevel)),
+1
View File
@@ -21,6 +21,7 @@ export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const envName = (process.env.IMMICH_ENV || 'production').toUpperCase(); export const envName = (process.env.IMMICH_ENV || 'production').toUpperCase();
export const isDev = () => process.env.IMMICH_ENV === 'development';
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload';
const HOST_SERVER_PORT = process.env.IMMICH_PORT || '2283'; const HOST_SERVER_PORT = process.env.IMMICH_PORT || '2283';
export const DEFAULT_EXTERNAL_DOMAIN = 'http://localhost:' + HOST_SERVER_PORT; export const DEFAULT_EXTERNAL_DOMAIN = 'http://localhost:' + HOST_SERVER_PORT;
-6
View File
@@ -328,9 +328,3 @@ export enum PaginationMode {
LIMIT_OFFSET = 'limit-offset', LIMIT_OFFSET = 'limit-offset',
SKIP_TAKE = 'skip-take', SKIP_TAKE = 'skip-take',
} }
export enum ImmichEnvironment {
DEVELOPMENT = 'development',
TESTING = 'testing',
PRODUCTION = 'production',
}
@@ -1,23 +1,16 @@
import { ImmichEnvironment, LogLevel } from 'src/enum';
import { VectorExtension } from 'src/interfaces/database.interface'; import { VectorExtension } from 'src/interfaces/database.interface';
export const IConfigRepository = 'IConfigRepository'; export const IConfigRepository = 'IConfigRepository';
export interface EnvData { export interface EnvData {
environment: ImmichEnvironment;
configFile?: string; configFile?: string;
logLevel?: LogLevel;
database: { database: {
skipMigrations: boolean; skipMigrations: boolean;
vectorExtension: VectorExtension; vectorExtension: VectorExtension;
}; };
storage: { storage: {
ignoreMountCheckErrors: boolean; ignoreMountCheckErrors: boolean;
}; };
nodeVersion?: string;
} }
export interface IConfigRepository { export interface IConfigRepository {
+1 -1
View File
@@ -5,7 +5,7 @@ export const ILoggerRepository = 'ILoggerRepository';
export interface ILoggerRepository { export interface ILoggerRepository {
setAppName(name: string): void; setAppName(name: string): void;
setContext(message: string): void; setContext(message: string): void;
setLogLevel(level: LogLevel | false): void; setLogLevel(level: LogLevel): void;
isLevelEnabled(level: LogLevel): boolean; isLevelEnabled(level: LogLevel): boolean;
verbose(message: any, ...args: any): void; verbose(message: any, ...args: any): void;
@@ -1,17 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { getVectorExtension } from 'src/database.config'; import { getVectorExtension } from 'src/database.config';
import { ImmichEnvironment, LogLevel } from 'src/enum';
import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
// TODO replace src/config validation with class-validator, here
@Injectable() @Injectable()
export class ConfigRepository implements IConfigRepository { export class ConfigRepository implements IConfigRepository {
getEnv(): EnvData { getEnv(): EnvData {
return { return {
environment: process.env.IMMICH_ENV as ImmichEnvironment,
configFile: process.env.IMMICH_CONFIG_FILE, configFile: process.env.IMMICH_CONFIG_FILE,
logLevel: process.env.IMMICH_LOG_LEVEL as LogLevel,
database: { database: {
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true', skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension: getVectorExtension(), vectorExtension: getVectorExtension(),
+2 -2
View File
@@ -25,8 +25,8 @@ export class LoggerRepository extends ConsoleLogger implements ILoggerRepository
return isLogLevelEnabled(level, LoggerRepository.logLevels); return isLogLevelEnabled(level, LoggerRepository.logLevels);
} }
setLogLevel(level: LogLevel | false): void { setLogLevel(level: LogLevel): void {
LoggerRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; LoggerRepository.logLevels = LOG_LEVELS.slice(LOG_LEVELS.indexOf(level));
} }
protected formatContext(context: string): string { protected formatContext(context: string): string {
+5 -4
View File
@@ -79,12 +79,13 @@ export class MediaRepository implements IMediaRepository {
failOn: options.processInvalidImages ? 'none' : 'error', failOn: options.processInvalidImages ? 'none' : 'error',
limitInputPixels: false, limitInputPixels: false,
raw: options.raw, raw: options.raw,
}) });
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.withIccProfile(options.colorspace);
if (!options.raw) { if (!options.raw) {
pipeline = pipeline.rotate(); pipeline = pipeline
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.withIccProfile(options.colorspace)
.rotate();
} }
if (options.crop) { if (options.crop) {
@@ -5,7 +5,6 @@ import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import sharp from 'sharp'; import sharp from 'sharp';
import { resourcePaths } from 'src/constants'; import { resourcePaths } from 'src/constants';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface'; import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
@@ -38,10 +37,7 @@ const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => {
@Instrumentation() @Instrumentation()
@Injectable() @Injectable()
export class ServerInfoRepository implements IServerInfoRepository { export class ServerInfoRepository implements IServerInfoRepository {
constructor( constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(ServerInfoRepository.name); this.logger.setContext(ServerInfoRepository.name);
} }
@@ -60,8 +56,6 @@ export class ServerInfoRepository implements IServerInfoRepository {
} }
async getBuildVersions(): Promise<ServerBuildVersions> { async getBuildVersions(): Promise<ServerBuildVersions> {
const { nodeVersion } = this.configRepository.getEnv();
const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([ const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([
maybeFirstLine('node --version'), maybeFirstLine('node --version'),
maybeFirstLine('ffmpeg -version'), maybeFirstLine('ffmpeg -version'),
@@ -73,7 +67,7 @@ export class ServerInfoRepository implements IServerInfoRepository {
.catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`)); .catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`));
return { return {
nodejs: nodejsOutput || nodeVersion || '', nodejs: nodejsOutput || process.env.NODE_VERSION || '',
exiftool: await exiftool.version(), exiftool: await exiftool.version(),
ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '', ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '',
libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips, libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips,
+6 -4
View File
@@ -4,18 +4,20 @@ import { IActivityRepository } from 'src/interfaces/activity.interface';
import { ActivityService } from 'src/services/activity.service'; import { ActivityService } from 'src/services/activity.service';
import { activityStub } from 'test/fixtures/activity.stub'; import { activityStub } from 'test/fixtures/activity.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(ActivityService.name, () => { describe(ActivityService.name, () => {
let sut: ActivityService; let sut: ActivityService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let activityMock: Mocked<IActivityRepository>; let activityMock: Mocked<IActivityRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, activityMock } = newTestService(ActivityService)); accessMock = newAccessRepositoryMock();
activityMock = newActivityRepositoryMock();
sut = new ActivityService(accessMock, activityMock);
}); });
it('should work', () => { it('should work', () => {
+18 -12
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { import {
ActivityCreateDto, ActivityCreateDto,
ActivityDto, ActivityDto,
@@ -13,14 +13,20 @@ import {
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ActivityEntity } from 'src/entities/activity.entity'; import { ActivityEntity } from 'src/entities/activity.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
@Injectable() @Injectable()
export class ActivityService extends BaseService { export class ActivityService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IActivityRepository) private repository: IActivityRepository,
) {}
async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> { async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
const activities = await this.activityRepository.search({ const activities = await this.repository.search({
userId: dto.userId, userId: dto.userId,
albumId: dto.albumId, albumId: dto.albumId,
assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId, assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId,
@@ -31,12 +37,12 @@ export class ActivityService extends BaseService {
} }
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> { async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
return { comments: await this.activityRepository.getStatistics(dto.assetId, dto.albumId) }; return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) };
} }
async create(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> { async create(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] });
const common = { const common = {
userId: auth.user.id, userId: auth.user.id,
@@ -49,7 +55,7 @@ export class ActivityService extends BaseService {
if (dto.type === ReactionType.LIKE) { if (dto.type === ReactionType.LIKE) {
delete dto.comment; delete dto.comment;
[activity] = await this.activityRepository.search({ [activity] = await this.repository.search({
...common, ...common,
// `null` will search for an album like // `null` will search for an album like
assetId: dto.assetId ?? null, assetId: dto.assetId ?? null,
@@ -59,7 +65,7 @@ export class ActivityService extends BaseService {
} }
if (!activity) { if (!activity) {
activity = await this.activityRepository.create({ activity = await this.repository.create({
...common, ...common,
isLiked: dto.type === ReactionType.LIKE, isLiked: dto.type === ReactionType.LIKE,
comment: dto.comment, comment: dto.comment,
@@ -70,7 +76,7 @@ export class ActivityService extends BaseService {
} }
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] });
await this.activityRepository.delete(id); await this.repository.delete(id);
} }
} }
+17 -5
View File
@@ -4,27 +4,39 @@ import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AlbumUserRole } from 'src/enum'; import { AlbumUserRole } from 'src/enum';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AlbumService } from 'src/services/album.service'; import { AlbumService } from 'src/services/album.service';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(AlbumService.name, () => { describe(AlbumService.name, () => {
let sut: AlbumService; let sut: AlbumService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
let albumUserMock: Mocked<IAlbumUserRepository>; let assetMock: Mocked<IAssetRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let albumUserMock: Mocked<IAlbumUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService)); accessMock = newAccessRepositoryMock();
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
albumUserMock = newAlbumUserRepositoryMock();
sut = new AlbumService(accessMock, albumMock, assetMock, eventMock, userMock, albumUserMock);
}); });
it('should work', () => { it('should work', () => {
+28 -15
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { import {
AddUsersDto, AddUsersDto,
AlbumInfoDto, AlbumInfoDto,
@@ -17,13 +17,26 @@ import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { BaseService } from 'src/services/base.service'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { checkAccess, requireAccess } from 'src/utils/access'; import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util'; import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable() @Injectable()
export class AlbumService extends BaseService { export class AlbumService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository,
) {}
async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> { async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
const [owned, shared, notShared] = await Promise.all([ const [owned, shared, notShared] = await Promise.all([
this.albumRepository.getOwned(auth.user.id), this.albumRepository.getOwned(auth.user.id),
@@ -82,7 +95,7 @@ export class AlbumService extends BaseService {
} }
async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> { async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [id] });
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
const album = await this.findOrFail(id, { withAssets }); const album = await this.findOrFail(id, { withAssets });
@@ -106,7 +119,7 @@ export class AlbumService extends BaseService {
} }
} }
const allowedAssetIdsSet = await checkAccess(this.accessRepository, { const allowedAssetIdsSet = await checkAccess(this.access, {
auth, auth,
permission: Permission.ASSET_SHARE, permission: Permission.ASSET_SHARE,
ids: dto.assetIds || [], ids: dto.assetIds || [],
@@ -130,7 +143,7 @@ export class AlbumService extends BaseService {
} }
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> { async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] });
const album = await this.findOrFail(id, { withAssets: true }); const album = await this.findOrFail(id, { withAssets: true });
@@ -153,17 +166,17 @@ export class AlbumService extends BaseService {
} }
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DELETE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_DELETE, ids: [id] });
await this.albumRepository.delete(id); await this.albumRepository.delete(id);
} }
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false }); const album = await this.findOrFail(id, { withAssets: false });
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] });
const results = await addAssets( const results = await addAssets(
auth, auth,
{ access: this.accessRepository, bulk: this.albumRepository }, { access: this.access, bulk: this.albumRepository },
{ parentId: id, assetIds: dto.ids }, { parentId: id, assetIds: dto.ids },
); );
@@ -182,12 +195,12 @@ export class AlbumService extends BaseService {
} }
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false }); const album = await this.findOrFail(id, { withAssets: false });
const results = await removeAssets( const results = await removeAssets(
auth, auth,
{ access: this.accessRepository, bulk: this.albumRepository }, { access: this.access, bulk: this.albumRepository },
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE }, { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE },
); );
@@ -203,7 +216,7 @@ export class AlbumService extends BaseService {
} }
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> { async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false }); const album = await this.findOrFail(id, { withAssets: false });
@@ -247,14 +260,14 @@ export class AlbumService extends BaseService {
// non-admin can remove themselves // non-admin can remove themselves
if (auth.user.id !== userId) { if (auth.user.id !== userId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
} }
await this.albumUserRepository.delete({ albumId: id, userId }); await this.albumUserRepository.delete({ albumId: id, userId });
} }
async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial<AlbumUserEntity>): Promise<void> { async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial<AlbumUserEntity>): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
} }
+6 -4
View File
@@ -5,17 +5,19 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { APIKeyService } from 'src/services/api-key.service'; import { APIKeyService } from 'src/services/api-key.service';
import { keyStub } from 'test/fixtures/api-key.stub'; import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(APIKeyService.name, () => { describe(APIKeyService.name, () => {
let sut: APIKeyService; let sut: APIKeyService;
let cryptoMock: Mocked<ICryptoRepository>;
let keyMock: Mocked<IKeyRepository>; let keyMock: Mocked<IKeyRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, cryptoMock, keyMock } = newTestService(APIKeyService)); cryptoMock = newCryptoRepositoryMock();
keyMock = newKeyRepositoryMock();
sut = new APIKeyService(cryptoMock, keyMock);
}); });
describe('create', () => { describe('create', () => {
+18 -12
View File
@@ -1,21 +1,27 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { APIKeyEntity } from 'src/entities/api-key.entity'; import { APIKeyEntity } from 'src/entities/api-key.entity';
import { BaseService } from 'src/services/base.service'; import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
@Injectable() @Injectable()
export class APIKeyService extends BaseService { export class APIKeyService {
constructor(
@Inject(ICryptoRepository) private crypto: ICryptoRepository,
@Inject(IKeyRepository) private repository: IKeyRepository,
) {}
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.cryptoRepository.newPassword(32); const secret = this.crypto.newPassword(32);
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) { if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
throw new BadRequestException('Cannot grant permissions you do not have'); throw new BadRequestException('Cannot grant permissions you do not have');
} }
const entity = await this.keyRepository.create({ const entity = await this.repository.create({
key: this.cryptoRepository.hashSha256(secret), key: this.crypto.hashSha256(secret),
name: dto.name || 'API Key', name: dto.name || 'API Key',
userId: auth.user.id, userId: auth.user.id,
permissions: dto.permissions, permissions: dto.permissions,
@@ -25,27 +31,27 @@ export class APIKeyService extends BaseService {
} }
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> { async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {
const exists = await this.keyRepository.getById(auth.user.id, id); const exists = await this.repository.getById(auth.user.id, id);
if (!exists) { if (!exists) {
throw new BadRequestException('API Key not found'); throw new BadRequestException('API Key not found');
} }
const key = await this.keyRepository.update(auth.user.id, id, { name: dto.name }); const key = await this.repository.update(auth.user.id, id, { name: dto.name });
return this.map(key); return this.map(key);
} }
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {
const exists = await this.keyRepository.getById(auth.user.id, id); const exists = await this.repository.getById(auth.user.id, id);
if (!exists) { if (!exists) {
throw new BadRequestException('API Key not found'); throw new BadRequestException('API Key not found');
} }
await this.keyRepository.delete(auth.user.id, id); await this.repository.delete(auth.user.id, id);
} }
async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> { async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> {
const key = await this.keyRepository.getById(auth.user.id, id); const key = await this.repository.getById(auth.user.id, id);
if (!key) { if (!key) {
throw new BadRequestException('API Key not found'); throw new BadRequestException('API Key not found');
} }
@@ -53,7 +59,7 @@ export class APIKeyService extends BaseService {
} }
async getAll(auth: AuthDto): Promise<APIKeyResponseDto[]> { async getAll(auth: AuthDto): Promise<APIKeyResponseDto[]> {
const keys = await this.keyRepository.getByUserId(auth.user.id); const keys = await this.repository.getByUserId(auth.user.id);
return keys.map((key) => this.map(key)); return keys.map((key) => this.map(key));
} }
@@ -6,7 +6,9 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, CacheControl } from 'src/enum'; import { AssetStatus, AssetType, CacheControl } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
@@ -14,8 +16,13 @@ import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
@@ -182,15 +189,24 @@ const copiedAsset = Object.freeze({
describe(AssetMediaService.name, () => { describe(AssetMediaService.name, () => {
let sut: AssetMediaService; let sut: AssetMediaService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService)); accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
eventMock = newEventRepositoryMock();
sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock);
}); });
describe('getUploadAssetIdByChecksum', () => { describe('getUploadAssetIdByChecksum', () => {
+32 -9
View File
@@ -1,4 +1,10 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Inject,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { extname } from 'node:path'; import { extname } from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
@@ -22,8 +28,13 @@ import {
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum'; import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum';
import { JobName } from 'src/interfaces/job.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { BaseService } from 'src/services/base.service'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess, requireUploadAccess } from 'src/utils/access'; import { requireAccess, requireUploadAccess } from 'src/utils/access';
import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
@@ -45,7 +56,19 @@ export interface UploadFile {
} }
@Injectable() @Injectable()
export class AssetMediaService extends BaseService { export class AssetMediaService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(AssetMediaService.name);
}
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
if (!checksum) { if (!checksum) {
return; return;
@@ -125,7 +148,7 @@ export class AssetMediaService extends BaseService {
sidecarFile?: UploadFile, sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
try { try {
await requireAccess(this.accessRepository, { await requireAccess(this.access, {
auth, auth,
permission: Permission.ASSET_UPLOAD, permission: Permission.ASSET_UPLOAD,
// do not need an id here, but the interface requires it // do not need an id here, but the interface requires it
@@ -159,7 +182,7 @@ export class AssetMediaService extends BaseService {
sidecarFile?: UploadFile, sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
try { try {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const asset = (await this.assetRepository.getById(id)) as AssetEntity; const asset = (await this.assetRepository.getById(id)) as AssetEntity;
this.requireQuota(auth, file.size); this.requireQuota(auth, file.size);
@@ -182,7 +205,7 @@ export class AssetMediaService extends BaseService {
} }
async downloadOriginal(auth: AuthDto, id: string): Promise<ImmichFileResponse> { async downloadOriginal(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] });
const asset = await this.findOrFail(id); const asset = await this.findOrFail(id);
if (!asset) { if (!asset) {
@@ -197,7 +220,7 @@ export class AssetMediaService extends BaseService {
} }
async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> { async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] });
const asset = await this.findOrFail(id); const asset = await this.findOrFail(id);
const size = dto.size ?? AssetMediaSize.THUMBNAIL; const size = dto.size ?? AssetMediaSize.THUMBNAIL;
@@ -220,7 +243,7 @@ export class AssetMediaService extends BaseService {
} }
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> { async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] });
const asset = await this.findOrFail(id); const asset = await this.findOrFail(id);
if (!asset) { if (!asset) {
+42 -8
View File
@@ -4,10 +4,13 @@ import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum'; import { AssetStatus, AssetType } from 'src/enum';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
@@ -15,8 +18,16 @@ import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
import { partnerStub } from 'test/fixtures/partner.stub'; import { partnerStub } from 'test/fixtures/partner.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
const stats: AssetStats = { const stats: AssetStats = {
@@ -34,14 +45,16 @@ const statResponse: AssetStatsResponseDto = {
describe(AssetService.name, () => { describe(AssetService.name, () => {
let sut: AssetService; let sut: AssetService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let eventMock: Mocked<IEventRepository>; let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let stackMock: Mocked<IStackRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>;
let stackMock: Mocked<IStackRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
@@ -54,8 +67,29 @@ describe(AssetService.name, () => {
}; };
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, assetMock, eventMock, jobMock, userMock, partnerMock, stackMock } = accessMock = newAccessRepositoryMock();
newTestService(AssetService)); assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
partnerMock = newPartnerRepositoryMock();
stackMock = newStackRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new AssetService(
accessMock,
assetMock,
configMock,
jobMock,
systemMock,
userMock,
eventMock,
partnerMock,
stackMock,
loggerMock,
);
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
}); });
+32 -6
View File
@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException, Inject } from '@nestjs/common';
import _ from 'lodash'; import _ from 'lodash';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import { import {
@@ -20,20 +20,46 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, Permission } from 'src/enum'; import { AssetStatus, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { import {
IAssetDeleteJob, IAssetDeleteJob,
IJobRepository,
ISidecarWriteJob, ISidecarWriteJob,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobItem, JobItem,
JobName, JobName,
JobStatus, JobStatus,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
export class AssetService extends BaseService { export class AssetService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IStackRepository) private stackRepository: IStackRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(AssetService.name);
}
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
const partnerIds = await getMyPartnerIds({ const partnerIds = await getMyPartnerIds({
userId: auth.user.id, userId: auth.user.id,
@@ -86,7 +112,7 @@ export class AssetService extends BaseService {
} }
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> { async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [id] });
const asset = await this.assetRepository.getById( const asset = await this.assetRepository.getById(
id, id,
@@ -135,7 +161,7 @@ export class AssetService extends BaseService {
} }
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository }; const repos = { asset: this.assetRepository, event: this.eventRepository };
@@ -178,7 +204,7 @@ export class AssetService extends BaseService {
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> { async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids }); await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids });
for (const id of ids) { for (const id of ids) {
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
@@ -275,7 +301,7 @@ export class AssetService extends BaseService {
async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise<void> { async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise<void> {
const { ids, force } = dto; const { ids, force } = dto;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids }); await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
await this.assetRepository.updateAll(ids, { await this.assetRepository.updateAll(ids, {
deletedAt: new Date(), deletedAt: new Date(),
status: force ? AssetStatus.DELETED : AssetStatus.TRASHED, status: force ? AssetStatus.DELETED : AssetStatus.TRASHED,
@@ -284,7 +310,7 @@ export class AssetService extends BaseService {
} }
async run(auth: AuthDto, dto: AssetJobsDto) { async run(auth: AuthDto, dto: AssetJobsDto) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
const jobs: JobItem[] = []; const jobs: JobItem[] = [];
+30 -2
View File
@@ -1,18 +1,46 @@
import { DatabaseAction, EntityType } from 'src/enum'; import { DatabaseAction, EntityType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuditService } from 'src/services/audit.service'; import { AuditService } from 'src/services/audit.service';
import { auditStub } from 'test/fixtures/audit.stub'; import { auditStub } from 'test/fixtures/audit.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(AuditService.name, () => { describe(AuditService.name, () => {
let sut: AuditService; let sut: AuditService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let auditMock: Mocked<IAuditRepository>; let auditMock: Mocked<IAuditRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, auditMock } = newTestService(AuditService)); accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
auditMock = newAuditRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new AuditService(accessMock, assetMock, cryptoMock, personMock, auditMock, storageMock, userMock, loggerMock);
}); });
it('should work', () => { it('should work', () => {
+26 -6
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
@@ -21,24 +21,44 @@ import {
StorageFolder, StorageFolder,
UserPathType, UserPathType,
} from 'src/enum'; } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class AuditService extends BaseService { export class AuditService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IAuditRepository) private repository: IAuditRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(AuditService.name);
}
async handleCleanup(): Promise<JobStatus> { async handleCleanup(): Promise<JobStatus> {
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> { async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
const userId = dto.userId || auth.user.id; const userId = dto.userId || auth.user.id;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] });
const audits = await this.auditRepository.getAfter(dto.after, { const audits = await this.repository.getAfter(dto.after, {
userIds: [userId], userIds: [userId],
entityType: dto.entityType, entityType: dto.entityType,
action: DatabaseAction.DELETE, action: DatabaseAction.DELETE,
+45 -15
View File
@@ -5,8 +5,10 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType } from 'src/enum'; import { AuthType } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -18,7 +20,15 @@ import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mock, Mocked, vitest } from 'vitest'; import { Mock, Mocked, vitest } from 'vitest';
// const token = Buffer.from('my-api-key', 'utf8').toString('base64'); // const token = Buffer.from('my-api-key', 'utf8').toString('base64');
@@ -49,14 +59,15 @@ const oauthUserWithDefaultQuota = {
describe('AuthService', () => { describe('AuthService', () => {
let sut: AuthService; let sut: AuthService;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>; let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let keyMock: Mocked<IKeyRepository>;
let sessionMock: Mocked<ISessionRepository>;
let sharedLinkMock: Mocked<ISharedLinkRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let sessionMock: Mocked<ISessionRepository>;
let shareMock: Mocked<ISharedLinkRepository>;
let keyMock: Mocked<IKeyRepository>;
let callbackMock: Mock; let callbackMock: Mock;
let userinfoMock: Mock; let userinfoMock: Mock;
@@ -81,8 +92,27 @@ describe('AuthService', () => {
}), }),
} as any); } as any);
({ sut, cryptoMock, eventMock, keyMock, sessionMock, sharedLinkMock, systemMock, userMock } = configMock = newConfigRepositoryMock();
newTestService(AuthService)); cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
sessionMock = newSessionRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
sut = new AuthService(
configMock,
cryptoMock,
eventMock,
systemMock,
loggerMock,
userMock,
sessionMock,
shareMock,
keyMock,
);
}); });
it('should be defined', () => { it('should be defined', () => {
@@ -267,7 +297,7 @@ describe('AuthService', () => {
describe('validate - shared key', () => { describe('validate - shared key', () => {
it('should not accept a non-existent key', async () => { it('should not accept a non-existent key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(null); shareMock.getByKey.mockResolvedValue(null);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': 'key' }, headers: { 'x-immich-share-key': 'key' },
@@ -278,7 +308,7 @@ describe('AuthService', () => {
}); });
it('should not accept an expired key', async () => { it('should not accept an expired key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': 'key' }, headers: { 'x-immich-share-key': 'key' },
@@ -289,7 +319,7 @@ describe('AuthService', () => {
}); });
it('should not accept a key without a user', async () => { it('should not accept a key without a user', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(null);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@@ -301,7 +331,7 @@ describe('AuthService', () => {
}); });
it('should accept a base64url key', async () => { it('should accept a base64url key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
userMock.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@@ -313,11 +343,11 @@ describe('AuthService', () => {
user: userStub.admin, user: userStub.admin,
sharedLink: sharedLinkStub.valid, sharedLink: sharedLinkStub.valid,
}); });
expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
}); });
it('should accept a hex key', async () => { it('should accept a hex key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
userMock.get.mockResolvedValue(userStub.admin); userMock.get.mockResolvedValue(userStub.admin);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@@ -329,7 +359,7 @@ describe('AuthService', () => {
user: userStub.admin, user: userStub.admin,
sharedLink: sharedLinkStub.valid, sharedLink: sharedLinkStub.valid,
}); });
expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
}); });
}); });
+24 -3
View File
@@ -1,6 +1,7 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
Inject,
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
UnauthorizedException, UnauthorizedException,
@@ -12,7 +13,6 @@ import { IncomingHttpHeaders } from 'node:http';
import { Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { OnEvent } from 'src/decorators';
import { import {
AuthDto, AuthDto,
ChangePasswordDto, ChangePasswordDto,
@@ -30,6 +30,15 @@ import {
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
@@ -63,8 +72,20 @@ export type ValidateRequest = {
@Injectable() @Injectable()
export class AuthService extends BaseService { export class AuthService extends BaseService {
@OnEvent({ name: 'app.bootstrap' }) constructor(
onBootstrap() { @Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(AuthService.name);
custom.setHttpOptionsDefaults({ timeout: 30_000 }); custom.setHttpOptionsDefaults({ timeout: 30_000 });
} }
+2 -83
View File
@@ -1,97 +1,16 @@
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IConfigRepository } from 'src/interfaces/config.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { INotificationRepository } from 'src/interfaces/notification.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { getConfig, updateConfig } from 'src/utils/config'; import { getConfig, updateConfig } from 'src/utils/config';
export class BaseService { export class BaseService {
protected storageCore: StorageCore;
constructor( constructor(
@Inject(ILoggerRepository) protected logger: ILoggerRepository,
@Inject(IAccessRepository) protected accessRepository: IAccessRepository,
@Inject(IActivityRepository) protected activityRepository: IActivityRepository,
@Inject(IAuditRepository) protected auditRepository: IAuditRepository,
@Inject(IAlbumRepository) protected albumRepository: IAlbumRepository,
@Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository,
@Inject(IAssetRepository) protected assetRepository: IAssetRepository,
@Inject(IConfigRepository) protected configRepository: IConfigRepository, @Inject(IConfigRepository) protected configRepository: IConfigRepository,
@Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) protected eventRepository: IEventRepository,
@Inject(IJobRepository) protected jobRepository: IJobRepository,
@Inject(IKeyRepository) protected keyRepository: IKeyRepository,
@Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository,
@Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository,
@Inject(IMapRepository) protected mapRepository: IMapRepository,
@Inject(IMediaRepository) protected mediaRepository: IMediaRepository,
@Inject(IMemoryRepository) protected memoryRepository: IMemoryRepository,
@Inject(IMetadataRepository) protected metadataRepository: IMetadataRepository,
@Inject(IMetricRepository) protected metricRepository: IMetricRepository,
@Inject(IMoveRepository) protected moveRepository: IMoveRepository,
@Inject(INotificationRepository) protected notificationRepository: INotificationRepository,
@Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository,
@Inject(IPersonRepository) protected personRepository: IPersonRepository,
@Inject(ISearchRepository) protected searchRepository: ISearchRepository,
@Inject(IServerInfoRepository) protected serverInfoRepository: IServerInfoRepository,
@Inject(ISessionRepository) protected sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository,
@Inject(IStackRepository) protected stackRepository: IStackRepository,
@Inject(IStorageRepository) protected storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository, @Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) protected tagRepository: ITagRepository, @Inject(ILoggerRepository) protected logger: ILoggerRepository,
@Inject(ITrashRepository) protected trashRepository: ITrashRepository, ) {}
@Inject(IUserRepository) protected userRepository: IUserRepository,
@Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository,
@Inject(IViewRepository) protected viewRepository: IViewRepository,
) {
this.logger.setContext(this.constructor.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
private get repos() { private get repos() {
return { return {
+20 -2
View File
@@ -1,16 +1,34 @@
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, describe, it } from 'vitest'; import { Mocked, describe, it } from 'vitest';
describe(CliService.name, () => { describe(CliService.name, () => {
let sut: CliService; let sut: CliService;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, userMock } = newTestService(CliService)); configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new CliService(configMock, cryptoMock, systemMock, userMock, loggerMock);
}); });
describe('resetAdminPassword', () => { describe('resetAdminPassword', () => {
+17 -1
View File
@@ -1,10 +1,26 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@Injectable() @Injectable()
export class CliService extends BaseService { export class CliService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(CliService.name);
}
async listUsers(): Promise<UserAdminResponseDto[]> { async listUsers(): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true }); const users = await this.userRepository.getList({ withDeleted: true });
return users.map((user) => mapUserAdmin(user)); return users.map((user) => mapUserAdmin(user));
+8 -4
View File
@@ -7,13 +7,13 @@ import {
} from 'src/interfaces/database.interface'; } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { DatabaseService } from 'src/services/database.service'; import { DatabaseService } from 'src/services/database.service';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(DatabaseService.name, () => { describe(DatabaseService.name, () => {
let sut: DatabaseService; let sut: DatabaseService;
let configMock: Mocked<IConfigRepository>; let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>; let databaseMock: Mocked<IDatabaseRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
@@ -24,7 +24,11 @@ describe(DatabaseService.name, () => {
let versionAboveRange: string; let versionAboveRange: string;
beforeEach(() => { beforeEach(() => {
({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService)); configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new DatabaseService(configMock, databaseMock, loggerMock);
extensionRange = '0.2.x'; extensionRange = '0.2.x';
databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange);
+13 -3
View File
@@ -1,15 +1,17 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import semver from 'semver'; import semver from 'semver';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { import {
DatabaseExtension, DatabaseExtension,
DatabaseLock, DatabaseLock,
EXTENSION_NAMES, EXTENSION_NAMES,
IDatabaseRepository,
VectorExtension, VectorExtension,
VectorIndex, VectorIndex,
} from 'src/interfaces/database.interface'; } from 'src/interfaces/database.interface';
import { BaseService } from 'src/services/base.service'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
type CreateFailedArgs = { name: string; extension: string; otherName: string }; type CreateFailedArgs = { name: string; extension: string; otherName: string };
type UpdateFailedArgs = { name: string; extension: string; availableVersion: string }; type UpdateFailedArgs = { name: string; extension: string; availableVersion: string };
@@ -61,9 +63,17 @@ const messages = {
const RETRY_DURATION = Duration.fromObject({ seconds: 5 }); const RETRY_DURATION = Duration.fromObject({ seconds: 5 });
@Injectable() @Injectable()
export class DatabaseService extends BaseService { export class DatabaseService {
private reconnection?: NodeJS.Timeout; private reconnection?: NodeJS.Timeout;
constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(DatabaseService.name);
}
@OnEvent({ name: 'app.bootstrap', priority: -200 }) @OnEvent({ name: 'app.bootstrap', priority: -200 })
async onBootstrap() { async onBootstrap() {
const version = await this.databaseRepository.getPostgresVersion(); const version = await this.databaseRepository.getPostgresVersion();
+12 -3
View File
@@ -2,12 +2,15 @@ import { BadRequestException } from '@nestjs/common';
import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadResponseDto } from 'src/dtos/download.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { DownloadService } from 'src/services/download.service'; import { DownloadService } from 'src/services/download.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { Readable } from 'typeorm/platform/PlatformTools.js'; import { Readable } from 'typeorm/platform/PlatformTools.js';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
@@ -25,6 +28,7 @@ describe(DownloadService.name, () => {
let sut: DownloadService; let sut: DownloadService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
it('should work', () => { it('should work', () => {
@@ -32,7 +36,12 @@ describe(DownloadService.name, () => {
}); });
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, assetMock, storageMock } = newTestService(DownloadService)); accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
loggerMock = newLoggerRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new DownloadService(accessMock, assetMock, loggerMock, storageMock);
}); });
describe('downloadArchive', () => { describe('downloadArchive', () => {
+19 -8
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { parse } from 'node:path'; import { parse } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto';
@@ -6,15 +6,26 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { ImmichReadStream } from 'src/interfaces/storage.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { BaseService } from 'src/services/base.service'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
import { getPreferences } from 'src/utils/preferences'; import { getPreferences } from 'src/utils/preferences';
@Injectable() @Injectable()
export class DownloadService extends BaseService { export class DownloadService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.logger.setContext(DownloadService.name);
}
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> { async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
const archives: DownloadArchiveInfo[] = []; const archives: DownloadArchiveInfo[] = [];
@@ -62,7 +73,7 @@ export class DownloadService extends BaseService {
} }
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> { async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds });
const zip = this.storageRepository.createZipStream(); const zip = this.storageRepository.createZipStream();
const assets = await this.assetRepository.getByIds(dto.assetIds); const assets = await this.assetRepository.getByIds(dto.assetIds);
@@ -105,20 +116,20 @@ export class DownloadService extends BaseService {
if (dto.assetIds) { if (dto.assetIds) {
const assetIds = dto.assetIds; const assetIds = dto.assetIds;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds });
const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true }); const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true });
return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets })); return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets }));
} }
if (dto.albumId) { if (dto.albumId) {
const albumId = dto.albumId; const albumId = dto.albumId;
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] });
return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId));
} }
if (dto.userId) { if (dto.userId) {
const userId = dto.userId; const userId = dto.userId;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); await requireAccess(this.access, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] });
return usePagination(PAGINATION_SIZE, (pagination) => return usePagination(PAGINATION_SIZE, (pagination) =>
this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), this.assetRepository.getByUserId(pagination, userId, { isVisible: true }),
); );
+23 -6
View File
@@ -1,4 +1,6 @@
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
@@ -6,22 +8,37 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { DuplicateService } from 'src/services/duplicate.service'; import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service'; import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, beforeEach, vitest } from 'vitest'; import { Mocked, beforeEach, vitest } from 'vitest';
vitest.useFakeTimers(); vitest.useFakeTimers();
describe(SearchService.name, () => { describe(SearchService.name, () => {
let sut: DuplicateService; let sut: DuplicateService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>; let configMock: Mocked<IConfigRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let searchMock: Mocked<ISearchRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let searchMock: Mocked<ISearchRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let jobMock: Mocked<IJobRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, assetMock, jobMock, loggerMock, searchMock, systemMock } = newTestService(DuplicateService)); assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock();
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new DuplicateService(configMock, systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock);
}); });
it('should work', () => { it('should work', () => {
+28 -4
View File
@@ -1,11 +1,22 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { AssetDuplicateResult } from 'src/interfaces/search.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
IEntityJob,
IJobRepository,
JOBS_ASSET_PAGINATION_SIZE,
JobName,
JobStatus,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { isDuplicateDetectionEnabled } from 'src/utils/misc';
@@ -13,6 +24,19 @@ import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class DuplicateService extends BaseService { export class DuplicateService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(DuplicateService.name);
}
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> { async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
+27 -2
View File
@@ -1,6 +1,8 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { import {
IJobRepository, IJobRepository,
JobCommand, JobCommand,
@@ -10,10 +12,20 @@ import {
JobStatus, JobStatus,
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
const makeMockHandlers = (status: JobStatus) => { const makeMockHandlers = (status: JobStatus) => {
@@ -27,11 +39,24 @@ const makeMockHandlers = (status: JobStatus) => {
describe(JobService.name, () => { describe(JobService.name, () => {
let sut: JobService; let sut: JobService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let personMock: Mocked<IPersonRepository>;
let metricMock: Mocked<IMetricRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, assetMock, jobMock, systemMock } = newTestService(JobService)); assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
personMock = newPersonRepositoryMock();
metricMock = newMetricRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new JobService(assetMock, configMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock);
}); });
it('should work', () => { it('should work', () => {
+23 -2
View File
@@ -1,12 +1,15 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { snakeCase } from 'lodash'; import { snakeCase } from 'lodash';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType, ManualJobName } from 'src/enum'; import { AssetType, ManualJobName } from 'src/enum';
import { ArgOf } from 'src/interfaces/event.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { import {
ConcurrentQueueName, ConcurrentQueueName,
IJobRepository,
JobCommand, JobCommand,
JobHandler, JobHandler,
JobItem, JobItem,
@@ -15,6 +18,10 @@ import {
QueueCleanType, QueueCleanType,
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
const asJobItem = (dto: JobCreateDto): JobItem => { const asJobItem = (dto: JobCreateDto): JobItem => {
@@ -41,6 +48,20 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
export class JobService extends BaseService { export class JobService extends BaseService {
private isMicroservices = false; private isMicroservices = false;
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IMetricRepository) private metricRepository: IMetricRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(JobService.name);
}
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
onBootstrap(app: ArgOf<'app.bootstrap'>) { onBootstrap(app: ArgOf<'app.bootstrap'>) {
this.isMicroservices = app === 'microservices'; this.isMicroservices = app === 'microservices';
+36 -3
View File
@@ -5,6 +5,8 @@ import { mapLibrary } from 'src/dtos/library.dto';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AssetType } from 'src/enum'; import { AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { import {
IJobRepository, IJobRepository,
@@ -15,6 +17,7 @@ import {
JobStatus, JobStatus,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { LibraryService } from 'src/services/library.service'; import { LibraryService } from 'src/services/library.service';
@@ -23,8 +26,15 @@ import { authStub } from 'test/fixtures/auth.stub';
import { libraryStub } from 'test/fixtures/library.stub'; import { libraryStub } from 'test/fixtures/library.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { makeMockWatcher } from 'test/repositories/storage.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newTestService } from 'test/utils'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
async function* mockWalk() { async function* mockWalk() {
@@ -35,14 +45,37 @@ describe(LibraryService.name, () => {
let sut: LibraryService; let sut: LibraryService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>; let databaseMock: Mocked<IDatabaseRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let libraryMock: Mocked<ILibraryRepository>; let libraryMock: Mocked<ILibraryRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, assetMock, databaseMock, jobMock, libraryMock, storageMock, systemMock } = newTestService(LibraryService)); configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
libraryMock = newLibraryRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new LibraryService(
assetMock,
configMock,
cryptoMock,
databaseMock,
jobMock,
libraryMock,
storageMock,
systemMock,
loggerMock,
);
databaseMock.tryLock.mockResolvedValue(true); databaseMock.tryLock.mockResolvedValue(true);
}); });
+39 -16
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { R_OK } from 'node:constants'; import { R_OK } from 'node:constants';
import path, { basename, parse } from 'node:path'; import path, { basename, parse } from 'node:path';
import picomatch from 'picomatch'; import picomatch from 'picomatch';
@@ -17,16 +17,24 @@ import {
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity'; import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType } from 'src/enum'; import { AssetType } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { import {
IEntityJob, IEntityJob,
IJobRepository,
ILibraryAssetJob, ILibraryAssetJob,
ILibraryFileJob, ILibraryFileJob,
JobName, JobName,
JOBS_LIBRARY_PAGINATION_SIZE, JOBS_LIBRARY_PAGINATION_SIZE,
JobStatus, JobStatus,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
@@ -39,6 +47,21 @@ export class LibraryService extends BaseService {
private watchLock = false; private watchLock = false;
private watchers: Record<string, () => Promise<void>> = {}; private watchers: Record<string, () => Promise<void>> = {};
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private repository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(LibraryService.name);
}
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
async onBootstrap() { async onBootstrap() {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
@@ -194,14 +217,14 @@ export class LibraryService extends BaseService {
return false; return false;
} }
const libraries = await this.libraryRepository.getAll(false); const libraries = await this.repository.getAll(false);
for (const library of libraries) { for (const library of libraries) {
await this.watch(library.id); await this.watch(library.id);
} }
} }
async getStatistics(id: string): Promise<LibraryStatsResponseDto> { async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
const statistics = await this.libraryRepository.getStatistics(id); const statistics = await this.repository.getStatistics(id);
if (!statistics) { if (!statistics) {
throw new BadRequestException(`Library ${id} not found`); throw new BadRequestException(`Library ${id} not found`);
} }
@@ -214,13 +237,13 @@ export class LibraryService extends BaseService {
} }
async getAll(): Promise<LibraryResponseDto[]> { async getAll(): Promise<LibraryResponseDto[]> {
const libraries = await this.libraryRepository.getAll(false); const libraries = await this.repository.getAll(false);
return libraries.map((library) => mapLibrary(library)); return libraries.map((library) => mapLibrary(library));
} }
async handleQueueCleanup(): Promise<JobStatus> { async handleQueueCleanup(): Promise<JobStatus> {
this.logger.debug('Cleaning up any pending library deletions'); this.logger.debug('Cleaning up any pending library deletions');
const pendingDeletion = await this.libraryRepository.getAllDeleted(); const pendingDeletion = await this.repository.getAllDeleted();
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })),
); );
@@ -228,7 +251,7 @@ export class LibraryService extends BaseService {
} }
async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> { async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> {
const library = await this.libraryRepository.create({ const library = await this.repository.create({
ownerId: dto.ownerId, ownerId: dto.ownerId,
name: dto.name ?? 'New External Library', name: dto.name ?? 'New External Library',
importPaths: dto.importPaths ?? [], importPaths: dto.importPaths ?? [],
@@ -303,7 +326,7 @@ export class LibraryService extends BaseService {
async update(id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> { async update(id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
await this.findOrFail(id); await this.findOrFail(id);
const library = await this.libraryRepository.update({ id, ...dto }); const library = await this.repository.update({ id, ...dto });
if (dto.importPaths) { if (dto.importPaths) {
const validation = await this.validate(id, { importPaths: dto.importPaths }); const validation = await this.validate(id, { importPaths: dto.importPaths });
@@ -326,7 +349,7 @@ export class LibraryService extends BaseService {
await this.unwatch(id); await this.unwatch(id);
} }
await this.libraryRepository.softDelete(id); await this.repository.softDelete(id);
await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } });
} }
@@ -356,7 +379,7 @@ export class LibraryService extends BaseService {
if (!assetsFound) { if (!assetsFound) {
this.logger.log(`Deleting library ${libraryId}`); this.logger.log(`Deleting library ${libraryId}`);
await this.libraryRepository.delete(libraryId); await this.repository.delete(libraryId);
} }
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@@ -384,7 +407,7 @@ export class LibraryService extends BaseService {
this.logger.log(`Importing new library asset: ${assetPath}`); this.logger.log(`Importing new library asset: ${assetPath}`);
const library = await this.libraryRepository.get(job.id, true); const library = await this.repository.get(job.id, true);
if (!library || library.deletedAt) { if (!library || library.deletedAt) {
this.logger.error('Cannot import asset into deleted library'); this.logger.error('Cannot import asset into deleted library');
return JobStatus.FAILED; return JobStatus.FAILED;
@@ -454,7 +477,7 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} });
const libraries = await this.libraryRepository.getAll(true); const libraries = await this.repository.getAll(true);
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
libraries.map((library) => ({ libraries.map((library) => ({
name: JobName.LIBRARY_QUEUE_SYNC_FILES, name: JobName.LIBRARY_QUEUE_SYNC_FILES,
@@ -530,7 +553,7 @@ export class LibraryService extends BaseService {
} }
async handleQueueSyncFiles(job: IEntityJob): Promise<JobStatus> { async handleQueueSyncFiles(job: IEntityJob): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.id); const library = await this.repository.get(job.id);
if (!library) { if (!library) {
this.logger.debug(`Library ${job.id} not found, skipping refresh`); this.logger.debug(`Library ${job.id} not found, skipping refresh`);
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -575,13 +598,13 @@ export class LibraryService extends BaseService {
this.logger.warn(`No valid import paths found for library ${library.id}`); this.logger.warn(`No valid import paths found for library ${library.id}`);
} }
await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() }); await this.repository.update({ id: job.id, refreshedAt: new Date() });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleQueueSyncAssets(job: IEntityJob): Promise<JobStatus> { async handleQueueSyncAssets(job: IEntityJob): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.id); const library = await this.repository.get(job.id);
if (!library) { if (!library) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
@@ -613,7 +636,7 @@ export class LibraryService extends BaseService {
} }
private async findOrFail(id: string) { private async findOrFail(id: string) {
const library = await this.libraryRepository.get(id); const library = await this.repository.get(id);
if (!library) { if (!library) {
throw new BadRequestException('Library not found'); throw new BadRequestException('Library not found');
} }
+11 -4
View File
@@ -1,19 +1,26 @@
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IMapRepository } from 'src/interfaces/map.interface'; import { IMapRepository } from 'src/interfaces/map.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { MapService } from 'src/services/map.service'; import { MapService } from 'src/services/map.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newMapRepositoryMock } from 'test/repositories/map.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(MapService.name, () => { describe(MapService.name, () => {
let sut: MapService; let sut: MapService;
let albumMock: Mocked<IAlbumRepository>;
let mapMock: Mocked<IMapRepository>;
let partnerMock: Mocked<IPartnerRepository>; let partnerMock: Mocked<IPartnerRepository>;
let mapMock: Mocked<IMapRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, mapMock, partnerMock } = newTestService(MapService)); albumMock = newAlbumRepositoryMock();
partnerMock = newPartnerRepositoryMock();
mapMock = newMapRepositoryMock();
sut = new MapService(albumMock, partnerMock, mapMock);
}); });
describe('getMapMarkers', () => { describe('getMapMarkers', () => {
+11 -2
View File
@@ -1,9 +1,18 @@
import { Inject } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto';
import { BaseService } from 'src/services/base.service'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { getMyPartnerIds } from 'src/utils/asset.util'; import { getMyPartnerIds } from 'src/utils/asset.util';
export class MapService extends BaseService { export class MapService {
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IMapRepository) private mapRepository: IMapRepository,
) {}
async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
const userIds = [auth.user.id]; const userIds = [auth.user.id];
if (options.withPartners) { if (options.withPartners) {
+40 -5
View File
@@ -12,9 +12,12 @@ import {
VideoCodec, VideoCodec,
} from 'src/enum'; } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface'; import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -23,23 +26,55 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub'; import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(MediaService.name, () => { describe(MediaService.name, () => {
let sut: MediaService; let sut: MediaService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let mediaMock: Mocked<IMediaRepository>; let mediaMock: Mocked<IMediaRepository>;
let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>; let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, assetMock, jobMock, loggerMock, mediaMock, personMock, storageMock, systemMock } = assetMock = newAssetRepositoryMock();
newTestService(MediaService)); configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new MediaService(
assetMock,
configMock,
personMock,
jobMock,
mediaMock,
storageMock,
systemMock,
moveMock,
cryptoMock,
loggerMock,
);
}); });
it('should be defined', () => { it('should be defined', () => {
+44 -3
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
@@ -17,17 +17,31 @@ import {
VideoCodec, VideoCodec,
VideoContainer, VideoContainer,
} from 'src/enum'; } from 'src/enum';
import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { import {
IBaseJob, IBaseJob,
IEntityJob, IEntityJob,
IJobRepository,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobItem, JobItem,
JobName, JobName,
JobStatus, JobStatus,
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { AudioStreamInfo, TranscodeCommand, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
AudioStreamInfo,
IMediaRepository,
TranscodeCommand,
VideoFormat,
VideoStreamInfo,
} from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
@@ -36,9 +50,36 @@ import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class MediaService extends BaseService { export class MediaService extends BaseService {
private storageCore: StorageCore;
private maliOpenCL?: boolean; private maliOpenCL?: boolean;
private devices?: string[]; private devices?: string[];
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(MediaService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
+7 -5
View File
@@ -5,18 +5,20 @@ import { MemoryService } from 'src/services/memory.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { memoryStub } from 'test/fixtures/memory.stub'; import { memoryStub } from 'test/fixtures/memory.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(MemoryService.name, () => { describe(MemoryService.name, () => {
let sut: MemoryService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let memoryMock: Mocked<IMemoryRepository>; let memoryMock: Mocked<IMemoryRepository>;
let sut: MemoryService;
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, memoryMock } = newTestService(MemoryService)); accessMock = newAccessRepositoryMock();
memoryMock = newMemoryRepositoryMock();
sut = new MemoryService(accessMock, memoryMock);
}); });
it('should be defined', () => { it('should be defined', () => {
+24 -18
View File
@@ -1,22 +1,28 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { checkAccess, requireAccess } from 'src/utils/access'; import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util'; import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable() @Injectable()
export class MemoryService extends BaseService { export class MemoryService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IMemoryRepository) private repository: IMemoryRepository,
) {}
async search(auth: AuthDto) { async search(auth: AuthDto) {
const memories = await this.memoryRepository.search(auth.user.id); const memories = await this.repository.search(auth.user.id);
return memories.map((memory) => mapMemory(memory)); return memories.map((memory) => mapMemory(memory));
} }
async get(auth: AuthDto, id: string): Promise<MemoryResponseDto> { async get(auth: AuthDto, id: string): Promise<MemoryResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] });
const memory = await this.findOrFail(id); const memory = await this.findOrFail(id);
return mapMemory(memory); return mapMemory(memory);
} }
@@ -25,12 +31,12 @@ export class MemoryService extends BaseService {
// TODO validate type/data combination // TODO validate type/data combination
const assetIds = dto.assetIds || []; const assetIds = dto.assetIds || [];
const allowedAssetIds = await checkAccess(this.accessRepository, { const allowedAssetIds = await checkAccess(this.access, {
auth, auth,
permission: Permission.ASSET_SHARE, permission: Permission.ASSET_SHARE,
ids: assetIds, ids: assetIds,
}); });
const memory = await this.memoryRepository.create({ const memory = await this.repository.create({
ownerId: auth.user.id, ownerId: auth.user.id,
type: dto.type, type: dto.type,
data: dto.data, data: dto.data,
@@ -44,9 +50,9 @@ export class MemoryService extends BaseService {
} }
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> { async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
const memory = await this.memoryRepository.update({ const memory = await this.repository.update({
id, id,
isSaved: dto.isSaved, isSaved: dto.isSaved,
memoryAt: dto.memoryAt, memoryAt: dto.memoryAt,
@@ -57,28 +63,28 @@ export class MemoryService extends BaseService {
} }
async remove(auth: AuthDto, id: string): Promise<void> { async remove(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_DELETE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.MEMORY_DELETE, ids: [id] });
await this.memoryRepository.delete(id); await this.repository.delete(id);
} }
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] });
const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const repos = { access: this.access, bulk: this.repository };
const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids }); const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids });
const hasSuccess = results.find(({ success }) => success); const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) { if (hasSuccess) {
await this.memoryRepository.update({ id, updatedAt: new Date() }); await this.repository.update({ id, updatedAt: new Date() });
} }
return results; return results;
} }
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const repos = { access: this.access, bulk: this.repository };
const results = await removeAssets(auth, repos, { const results = await removeAssets(auth, repos, {
parentId: id, parentId: id,
assetIds: dto.ids, assetIds: dto.ids,
@@ -87,14 +93,14 @@ export class MemoryService extends BaseService {
const hasSuccess = results.find(({ success }) => success); const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) { if (hasSuccess) {
await this.memoryRepository.update({ id, updatedAt: new Date() }); await this.repository.update({ id, updatedAt: new Date() });
} }
return results; return results;
} }
private async findOrFail(id: string) { private async findOrFail(id: string) {
const memory = await this.memoryRepository.get(id); const memory = await this.repository.get(id);
if (!memory) { if (!memory) {
throw new BadRequestException('Memory not found'); throw new BadRequestException('Memory not found');
} }
+62 -17
View File
@@ -6,12 +6,16 @@ import { ExifEntity } from 'src/entities/exif.entity';
import { AssetType, SourceType } from 'src/enum'; import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMapRepository } from 'src/interfaces/map.interface'; import { IMapRepository } from 'src/interfaces/map.interface';
import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -24,7 +28,23 @@ import { probeStub } from 'test/fixtures/media.stub';
import { metadataStub } from 'test/fixtures/metadata.stub'; import { metadataStub } from 'test/fixtures/metadata.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub'; import { tagStub } from 'test/fixtures/tag.stub';
import { newTestService } from 'test/utils'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMapRepositoryMock } from 'test/repositories/map.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(MetadataService.name, () => { describe(MetadataService.name, () => {
@@ -32,35 +52,60 @@ describe(MetadataService.name, () => {
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let cryptoMock: Mocked<ICryptoRepository>; let configMock: Mocked<IConfigRepository>;
let cryptoRepository: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let mapMock: Mocked<IMapRepository>; let mapMock: Mocked<IMapRepository>;
let mediaMock: Mocked<IMediaRepository>;
let metadataMock: Mocked<IMetadataRepository>; let metadataMock: Mocked<IMetadataRepository>;
let moveMock: Mocked<IMoveRepository>;
let mediaMock: Mocked<IMediaRepository>;
let personMock: Mocked<IPersonRepository>; let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let tagMock: Mocked<ITagRepository>; let tagMock: Mocked<ITagRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ albumMock = newAlbumRepositoryMock();
sut, assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoRepository = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
mapMock = newMapRepositoryMock();
metadataMock = newMetadataRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
eventMock = newEventRepositoryMock();
storageMock = newStorageRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
mediaMock = newMediaRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new MetadataService(
albumMock, albumMock,
assetMock, assetMock,
cryptoMock, configMock,
cryptoRepository,
databaseMock,
eventMock, eventMock,
jobMock, jobMock,
mapMock, mapMock,
mediaMock, mediaMock,
metadataMock, metadataMock,
moveMock,
personMock, personMock,
storageMock, storageMock,
systemMock, systemMock,
tagMock, tagMock,
userMock, userMock,
} = newTestService(MetadataService)); loggerMock,
);
}); });
afterEach(async () => { afterEach(async () => {
@@ -524,10 +569,10 @@ describe(MetadataService.name, () => {
EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoFile: new BinaryField(0, ''),
EmbeddedVideoType: 'MotionPhoto_Data', EmbeddedVideoType: 'MotionPhoto_Data',
}); });
cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null); assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
const video = randomBytes(512); const video = randomBytes(512);
metadataMock.extractBinaryTag.mockResolvedValue(video); metadataMock.extractBinaryTag.mockResolvedValue(video);
@@ -567,10 +612,10 @@ describe(MetadataService.name, () => {
EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoFile: new BinaryField(0, ''),
EmbeddedVideoType: 'MotionPhoto_Data', EmbeddedVideoType: 'MotionPhoto_Data',
}); });
cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null); assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
const video = randomBytes(512); const video = randomBytes(512);
metadataMock.extractBinaryTag.mockResolvedValue(video); metadataMock.extractBinaryTag.mockResolvedValue(video);
@@ -611,10 +656,10 @@ describe(MetadataService.name, () => {
MicroVideo: 1, MicroVideo: 1,
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null); assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
const video = randomBytes(512); const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video); storageMock.readFile.mockResolvedValue(video);
@@ -655,7 +700,7 @@ describe(MetadataService.name, () => {
MicroVideo: 1, MicroVideo: 1,
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null); assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset })); assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }));
const video = randomBytes(512); const video = randomBytes(512);
@@ -680,7 +725,7 @@ describe(MetadataService.name, () => {
MicroVideo: 1, MicroVideo: 1,
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset);
const video = randomBytes(512); const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video); storageMock.readFile.mockResolvedValue(video);
@@ -702,7 +747,7 @@ describe(MetadataService.name, () => {
MicroVideo: 1, MicroVideo: 1,
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
const video = randomBytes(512); const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video); storageMock.readFile.mockResolvedValue(video);
@@ -728,7 +773,7 @@ describe(MetadataService.name, () => {
MicroVideo: 1, MicroVideo: 1,
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null); assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
const video = randomBytes(512); const video = randomBytes(512);
+59 -12
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import _ from 'lodash'; import _ from 'lodash';
@@ -13,20 +13,32 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity'; import { ExifEntity } from 'src/entities/exif.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, SourceType } from 'src/enum'; import { AssetType, SourceType } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { import {
IBaseJob, IBaseJob,
IEntityJob, IEntityJob,
IJobRepository,
ISidecarWriteJob, ISidecarWriteJob,
JobName, JobName,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobStatus, JobStatus,
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ReverseGeocodeResult } from 'src/interfaces/map.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ImmichTags } from 'src/interfaces/metadata.interface'; import { IMapRepository, ReverseGeocodeResult } from 'src/interfaces/map.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { isFaceImportEnabled } from 'src/utils/misc'; import { isFaceImportEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@@ -87,6 +99,41 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
@Injectable() @Injectable()
export class MetadataService extends BaseService { export class MetadataService extends BaseService {
private storageCore: StorageCore;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMapRepository) private mapRepository: IMapRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IMetadataRepository) private repository: IMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) private tagRepository: ITagRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(MetadataService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) { async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== 'microservices') { if (app !== 'microservices') {
@@ -98,7 +145,7 @@ export class MetadataService extends BaseService {
@OnEvent({ name: 'app.shutdown' }) @OnEvent({ name: 'app.shutdown' })
async onShutdown() { async onShutdown() {
await this.metadataRepository.teardown(); await this.repository.teardown();
} }
@OnEvent({ name: 'config.update' }) @OnEvent({ name: 'config.update' })
@@ -325,7 +372,7 @@ export class MetadataService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
await this.metadataRepository.writeTags(sidecarPath, exif); await this.repository.writeTags(sidecarPath, exif);
if (!asset.sidecarPath) { if (!asset.sidecarPath) {
await this.assetRepository.update({ id, sidecarPath }); await this.assetRepository.update({ id, sidecarPath });
@@ -335,8 +382,8 @@ export class MetadataService extends BaseService {
} }
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> { private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
const mediaTags = await this.metadataRepository.readTags(asset.originalPath); const mediaTags = await this.repository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.metadataRepository.readTags(asset.sidecarPath) : {}; const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {};
const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {}; const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {};
// make sure dates comes from sidecar // make sure dates comes from sidecar
@@ -420,11 +467,11 @@ export class MetadataService extends BaseService {
// Samsung MotionPhoto video extraction // Samsung MotionPhoto video extraction
// HEIC-encoded // HEIC-encoded
if (hasMotionPhotoVideo) { if (hasMotionPhotoVideo) {
video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo'); video = await this.repository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo');
} }
// JPEG-encoded; HEIC also contains these tags, so this conditional must come second // JPEG-encoded; HEIC also contains these tags, so this conditional must come second
else if (hasEmbeddedVideoFile) { else if (hasEmbeddedVideoFile) {
video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile'); video = await this.repository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile');
} }
// Default video extraction // Default video extraction
else { else {
@@ -6,8 +6,10 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum'; import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
@@ -15,7 +17,15 @@ import { NotificationService } from 'src/services/notification.service';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const configs = { const configs = {
@@ -56,19 +66,39 @@ const configs = {
}; };
describe(NotificationService.name, () => { describe(NotificationService.name, () => {
let sut: NotificationService;
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let notificationMock: Mocked<INotificationRepository>; let notificationMock: Mocked<INotificationRepository>;
let sut: NotificationService;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } = albumMock = newAlbumRepositoryMock();
newTestService(NotificationService)); assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock();
notificationMock = newNotificationRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
sut = new NotificationService(
configMock,
eventMock,
systemMock,
notificationMock,
userMock,
jobMock,
loggerMock,
assetMock,
albumMock,
);
}); });
it('should work', () => { it('should work', () => {
+25 -3
View File
@@ -1,18 +1,25 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { ArgOf } from 'src/interfaces/event.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { import {
IEmailJob, IEmailJob,
IJobRepository,
INotifyAlbumInviteJob, INotifyAlbumInviteJob,
INotifyAlbumUpdateJob, INotifyAlbumUpdateJob,
INotifySignupJob, INotifySignupJob,
JobName, JobName,
JobStatus, JobStatus,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { EmailImageAttachment, EmailTemplate } from 'src/interfaces/notification.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { getFilenameExtension } from 'src/utils/file'; import { getFilenameExtension } from 'src/utils/file';
@@ -21,6 +28,21 @@ import { getPreferences } from 'src/utils/preferences';
@Injectable() @Injectable()
export class NotificationService extends BaseService { export class NotificationService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(NotificationService.name);
}
@OnEvent({ name: 'config.update' }) @OnEvent({ name: 'config.update' })
onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
this.eventRepository.clientBroadcast('on_config_update'); this.eventRepository.clientBroadcast('on_config_update');
+5 -2
View File
@@ -1,17 +1,20 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface';
import { PartnerService } from 'src/services/partner.service'; import { PartnerService } from 'src/services/partner.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { partnerStub } from 'test/fixtures/partner.stub'; import { partnerStub } from 'test/fixtures/partner.stub';
import { newTestService } from 'test/utils'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(PartnerService.name, () => { describe(PartnerService.name, () => {
let sut: PartnerService; let sut: PartnerService;
let partnerMock: Mocked<IPartnerRepository>; let partnerMock: Mocked<IPartnerRepository>;
let accessMock: Mocked<IAccessRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, partnerMock } = newTestService(PartnerService)); partnerMock = newPartnerRepositoryMock();
sut = new PartnerService(partnerMock, accessMock);
}); });
it('should work', () => { it('should work', () => {
+16 -11
View File
@@ -1,38 +1,43 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto'; import { mapUser } from 'src/dtos/user.dto';
import { PartnerEntity } from 'src/entities/partner.entity'; import { PartnerEntity } from 'src/entities/partner.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { BaseService } from 'src/services/base.service'; import { IPartnerRepository, PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
@Injectable() @Injectable()
export class PartnerService extends BaseService { export class PartnerService {
constructor(
@Inject(IPartnerRepository) private repository: IPartnerRepository,
@Inject(IAccessRepository) private access: IAccessRepository,
) {}
async create(auth: AuthDto, sharedWithId: string): Promise<PartnerResponseDto> { async create(auth: AuthDto, sharedWithId: string): Promise<PartnerResponseDto> {
const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId };
const exists = await this.partnerRepository.get(partnerId); const exists = await this.repository.get(partnerId);
if (exists) { if (exists) {
throw new BadRequestException(`Partner already exists`); throw new BadRequestException(`Partner already exists`);
} }
const partner = await this.partnerRepository.create(partnerId); const partner = await this.repository.create(partnerId);
return this.mapPartner(partner, PartnerDirection.SharedBy); return this.mapPartner(partner, PartnerDirection.SharedBy);
} }
async remove(auth: AuthDto, sharedWithId: string): Promise<void> { async remove(auth: AuthDto, sharedWithId: string): Promise<void> {
const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId };
const partner = await this.partnerRepository.get(partnerId); const partner = await this.repository.get(partnerId);
if (!partner) { if (!partner) {
throw new BadRequestException('Partner not found'); throw new BadRequestException('Partner not found');
} }
await this.partnerRepository.remove(partner); await this.repository.remove(partner);
} }
async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> { async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> {
const partners = await this.partnerRepository.getAll(auth.user.id); const partners = await this.repository.getAll(auth.user.id);
const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId'; const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners return partners
.filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users .filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users
@@ -41,10 +46,10 @@ export class PartnerService extends BaseService {
} }
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> { async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); await requireAccess(this.access, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] });
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.partnerRepository.update({ ...partnerId, inTimeline: dto.inTimeline }); const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline });
return this.mapPartner(entity, PartnerDirection.SharedWith); return this.mapPartner(entity, PartnerDirection.SharedWith);
} }
+46 -14
View File
@@ -4,10 +4,13 @@ import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
@@ -19,8 +22,19 @@ import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
@@ -53,33 +67,51 @@ const detectFaceMock: DetectedFaces = {
}; };
describe(PersonService.name, () => { describe(PersonService.name, () => {
let sut: PersonService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let cryptoMock: Mocked<ICryptoRepository>; let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let machineLearningMock: Mocked<IMachineLearningRepository>; let machineLearningMock: Mocked<IMachineLearningRepository>;
let mediaMock: Mocked<IMediaRepository>; let mediaMock: Mocked<IMediaRepository>;
let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>; let personMock: Mocked<IPersonRepository>;
let searchMock: Mocked<ISearchRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let searchMock: Mocked<ISearchRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let sut: PersonService;
beforeEach(() => { beforeEach(() => {
({ accessMock = newAccessRepositoryMock();
sut, assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock();
machineLearningMock = newMachineLearningRepositoryMock();
moveMock = newMoveRepositoryMock();
mediaMock = newMediaRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
searchMock = newSearchRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new PersonService(
accessMock, accessMock,
assetMock, assetMock,
cryptoMock, configMock,
jobMock,
machineLearningMock, machineLearningMock,
moveMock,
mediaMock, mediaMock,
personMock, personMock,
searchMock,
storageMock,
systemMock, systemMock,
} = newTestService(PersonService)); storageMock,
jobMock,
searchMock,
cryptoMock,
loggerMock,
);
}); });
it('should be defined', () => { it('should be defined', () => {
+93 -53
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { FACE_THUMBNAIL_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
@@ -31,11 +31,15 @@ import {
SourceType, SourceType,
SystemMetadataKey, SystemMetadataKey,
} from 'src/enum'; } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { import {
IBaseJob, IBaseJob,
IDeferrableJob, IDeferrableJob,
IEntityJob, IEntityJob,
IJobRepository,
INightlyJob, INightlyJob,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobItem, JobItem,
@@ -43,9 +47,14 @@ import {
JobStatus, JobStatus,
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { BoundingBox } from 'src/interfaces/machine-learning.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { CropOptions, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { UpdateFacesData } from 'src/interfaces/person.interface'; import { CropOptions, IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access'; import { checkAccess, requireAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
@@ -57,6 +66,37 @@ import { IsNull } from 'typeorm';
@Injectable() @Injectable()
export class PersonService extends BaseService { export class PersonService extends BaseService {
private storageCore: StorageCore;
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IPersonRepository) private repository: IPersonRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(PersonService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
repository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> { async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
const { withHidden = false, page, size } = dto; const { withHidden = false, page, size } = dto;
const pagination = { const pagination = {
@@ -65,11 +105,11 @@ export class PersonService extends BaseService {
}; };
const { machineLearning } = await this.getConfig({ withCache: false }); const { machineLearning } = await this.getConfig({ withCache: false });
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, {
minimumFaceCount: machineLearning.facialRecognition.minFaces, minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden, withHidden,
}); });
const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id); const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id);
return { return {
people: items.map((person) => mapPerson(person)), people: items.map((person) => mapPerson(person)),
@@ -80,15 +120,15 @@ export class PersonService extends BaseService {
} }
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> { async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
const person = await this.findOrFail(personId); const person = await this.findOrFail(personId);
const result: PersonResponseDto[] = []; const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = []; const changeFeaturePhoto: string[] = [];
for (const data of dto.data) { for (const data of dto.data) {
const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
for (const face of faces) { for (const face of faces) {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] });
if (person.faceAssetId === null) { if (person.faceAssetId === null) {
changeFeaturePhoto.push(person.id); changeFeaturePhoto.push(person.id);
} }
@@ -96,7 +136,7 @@ export class PersonService extends BaseService {
changeFeaturePhoto.push(face.person.id); changeFeaturePhoto.push(face.person.id);
} }
await this.personRepository.reassignFace(face.id, personId); await this.repository.reassignFace(face.id, personId);
} }
result.push(person); result.push(person);
@@ -109,12 +149,12 @@ export class PersonService extends BaseService {
} }
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> { async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] });
const face = await this.personRepository.getFaceById(dto.id); const face = await this.repository.getFaceById(dto.id);
const person = await this.findOrFail(personId); const person = await this.findOrFail(personId);
await this.personRepository.reassignFace(face.id, personId); await this.repository.reassignFace(face.id, personId);
if (person.faceAssetId === null) { if (person.faceAssetId === null) {
await this.createNewFeaturePhoto([person.id]); await this.createNewFeaturePhoto([person.id]);
} }
@@ -126,8 +166,8 @@ export class PersonService extends BaseService {
} }
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> { async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [dto.id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [dto.id] });
const faces = await this.personRepository.getFaces(dto.id); const faces = await this.repository.getFaces(dto.id);
return faces.map((asset) => mapFaces(asset, auth)); return faces.map((asset) => mapFaces(asset, auth));
} }
@@ -138,10 +178,10 @@ export class PersonService extends BaseService {
const jobs: JobItem[] = []; const jobs: JobItem[] = [];
for (const personId of changeFeaturePhoto) { for (const personId of changeFeaturePhoto) {
const assetFace = await this.personRepository.getRandomFace(personId); const assetFace = await this.repository.getRandomFace(personId);
if (assetFace !== null) { if (assetFace !== null) {
await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); await this.repository.update({ id: personId, faceAssetId: assetFace.id });
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } });
} }
} }
@@ -150,18 +190,18 @@ export class PersonService extends BaseService {
} }
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> { async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] });
return this.findOrFail(id).then(mapPerson); return this.findOrFail(id).then(mapPerson);
} }
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> { async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] });
return this.personRepository.getStatistics(id); return this.repository.getStatistics(id);
} }
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> { async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] });
const person = await this.personRepository.getById(id); const person = await this.repository.getById(id);
if (!person || !person.thumbnailPath) { if (!person || !person.thumbnailPath) {
throw new NotFoundException(); throw new NotFoundException();
} }
@@ -174,13 +214,13 @@ export class PersonService extends BaseService {
} }
async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> { async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] });
const assets = await this.personRepository.getAssets(id); const assets = await this.repository.getAssets(id);
return assets.map((asset) => mapAsset(asset)); return assets.map((asset) => mapAsset(asset));
} }
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> { create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.personRepository.create({ return this.repository.create({
ownerId: auth.user.id, ownerId: auth.user.id,
name: dto.name, name: dto.name,
birthDate: dto.birthDate, birthDate: dto.birthDate,
@@ -189,14 +229,14 @@ export class PersonService extends BaseService {
} }
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] });
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
// TODO: set by faceId directly // TODO: set by faceId directly
let faceId: string | undefined = undefined; let faceId: string | undefined = undefined;
if (assetId) { if (assetId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [assetId] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [assetId] });
const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]); const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]);
if (!face) { if (!face) {
throw new BadRequestException('Invalid assetId for feature face'); throw new BadRequestException('Invalid assetId for feature face');
} }
@@ -204,7 +244,7 @@ export class PersonService extends BaseService {
faceId = face.id; faceId = face.id;
} }
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
if (assetId) { if (assetId) {
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
@@ -234,12 +274,12 @@ export class PersonService extends BaseService {
private async delete(people: PersonEntity[]) { private async delete(people: PersonEntity[]) {
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.delete(people); await this.repository.delete(people);
this.logger.debug(`Deleted ${people.length} people`); this.logger.debug(`Deleted ${people.length} people`);
} }
async handlePersonCleanup(): Promise<JobStatus> { async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.personRepository.getAllWithoutFaces(); const people = await this.repository.getAllWithoutFaces();
await this.delete(people); await this.delete(people);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@@ -251,7 +291,7 @@ export class PersonService extends BaseService {
} }
if (force) { if (force) {
await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.repository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.handlePersonCleanup(); await this.handlePersonCleanup();
} }
@@ -324,7 +364,7 @@ export class PersonService extends BaseService {
}); });
} }
const faceIds = await this.personRepository.createFaces(mappedFaces); const faceIds = await this.repository.createFaces(mappedFaces);
await this.jobRepository.queueAll(faceIds.map((id) => ({ name: JobName.FACIAL_RECOGNITION, data: { id } }))); await this.jobRepository.queueAll(faceIds.map((id) => ({ name: JobName.FACIAL_RECOGNITION, data: { id } })));
} }
@@ -347,7 +387,7 @@ export class PersonService extends BaseService {
if (nightly) { if (nightly) {
const [state, latestFaceDate] = await Promise.all([ const [state, latestFaceDate] = await Promise.all([
this.systemMetadataRepository.get(SystemMetadataKey.FACIAL_RECOGNITION_STATE), this.systemMetadataRepository.get(SystemMetadataKey.FACIAL_RECOGNITION_STATE),
this.personRepository.getLatestFaceDate(), this.repository.getLatestFaceDate(),
]); ]);
if (state?.lastRun && latestFaceDate && state.lastRun > latestFaceDate) { if (state?.lastRun && latestFaceDate && state.lastRun > latestFaceDate) {
@@ -359,7 +399,7 @@ export class PersonService extends BaseService {
const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION);
if (force) { if (force) {
await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.repository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.handlePersonCleanup(); await this.handlePersonCleanup();
} else if (waiting) { } else if (waiting) {
this.logger.debug( this.logger.debug(
@@ -370,7 +410,7 @@ export class PersonService extends BaseService {
const lastRun = new Date().toISOString(); const lastRun = new Date().toISOString();
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.personRepository.getAllFaces(pagination, { this.repository.getAllFaces(pagination, {
where: force ? undefined : { personId: IsNull(), sourceType: IsNull() }, where: force ? undefined : { personId: IsNull(), sourceType: IsNull() },
}), }),
); );
@@ -392,7 +432,7 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const face = await this.personRepository.getFaceByIdWithAssets( const face = await this.repository.getFaceByIdWithAssets(
id, id,
{ person: true, asset: true, faceSearch: true }, { person: true, asset: true, faceSearch: true },
{ id: true, personId: true, sourceType: true, faceSearch: { embedding: true } }, { id: true, personId: true, sourceType: true, faceSearch: { embedding: true } },
@@ -417,7 +457,7 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const matches = await this.searchRepository.searchFaces({ const matches = await this.smartInfoRepository.searchFaces({
userIds: [face.asset.ownerId], userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding, embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance, maxDistance: machineLearning.facialRecognition.maxDistance,
@@ -441,7 +481,7 @@ export class PersonService extends BaseService {
let personId = matches.find((match) => match.face.personId)?.face.personId; let personId = matches.find((match) => match.face.personId)?.face.personId;
if (!personId) { if (!personId) {
const matchWithPerson = await this.searchRepository.searchFaces({ const matchWithPerson = await this.smartInfoRepository.searchFaces({
userIds: [face.asset.ownerId], userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding, embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance, maxDistance: machineLearning.facialRecognition.maxDistance,
@@ -456,21 +496,21 @@ export class PersonService extends BaseService {
if (isCore && !personId) { if (isCore && !personId) {
this.logger.log(`Creating new person for face ${id}`); this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); const newPerson = await this.repository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
personId = newPerson.id; personId = newPerson.id;
} }
if (personId) { if (personId) {
this.logger.debug(`Assigning face ${id} to person ${personId}`); this.logger.debug(`Assigning face ${id} to person ${personId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); await this.repository.reassignFaces({ faceIds: [id], newPersonId: personId });
} }
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handlePersonMigration({ id }: IEntityJob): Promise<JobStatus> { async handlePersonMigration({ id }: IEntityJob): Promise<JobStatus> {
const person = await this.personRepository.getById(id); const person = await this.repository.getById(id);
if (!person) { if (!person) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
@@ -486,13 +526,13 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const person = await this.personRepository.getById(data.id); const person = await this.repository.getById(data.id);
if (!person?.faceAssetId) { if (!person?.faceAssetId) {
this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`); this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`);
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId); const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId);
if (face === null) { if (face === null) {
this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`); this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`);
return JobStatus.FAILED; return JobStatus.FAILED;
@@ -532,7 +572,7 @@ export class PersonService extends BaseService {
}; };
await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
await this.personRepository.update({ id: person.id, thumbnailPath }); await this.repository.update({ id: person.id, thumbnailPath });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@@ -543,13 +583,13 @@ export class PersonService extends BaseService {
throw new BadRequestException('Cannot merge a person into themselves'); throw new BadRequestException('Cannot merge a person into themselves');
} }
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] });
let primaryPerson = await this.findOrFail(id); let primaryPerson = await this.findOrFail(id);
const primaryName = primaryPerson.name || primaryPerson.id; const primaryName = primaryPerson.name || primaryPerson.id;
const results: BulkIdResponseDto[] = []; const results: BulkIdResponseDto[] = [];
const allowedIds = await checkAccess(this.accessRepository, { const allowedIds = await checkAccess(this.access, {
auth, auth,
permission: Permission.PERSON_MERGE, permission: Permission.PERSON_MERGE,
ids: mergeIds, ids: mergeIds,
@@ -563,7 +603,7 @@ export class PersonService extends BaseService {
} }
try { try {
const mergePerson = await this.personRepository.getById(mergeId); const mergePerson = await this.repository.getById(mergeId);
if (!mergePerson) { if (!mergePerson) {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND }); results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue; continue;
@@ -579,14 +619,14 @@ export class PersonService extends BaseService {
} }
if (Object.keys(update).length > 0) { if (Object.keys(update).length > 0) {
primaryPerson = await this.personRepository.update({ id: primaryPerson.id, ...update }); primaryPerson = await this.repository.update({ id: primaryPerson.id, ...update });
} }
const mergeName = mergePerson.name || mergePerson.id; const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`); this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData); await this.repository.reassignFaces(mergeData);
await this.delete([mergePerson]); await this.delete([mergePerson]);
this.logger.log(`Merged ${mergeName} into ${primaryName}`); this.logger.log(`Merged ${mergeName} into ${primaryName}`);
@@ -600,7 +640,7 @@ export class PersonService extends BaseService {
} }
private async findOrFail(id: string) { private async findOrFail(id: string) {
const person = await this.personRepository.getById(id); const person = await this.repository.getById(id);
if (!person) { if (!person) {
throw new BadRequestException('Person not found'); throw new BadRequestException('Person not found');
} }
+37 -3
View File
@@ -1,26 +1,60 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SearchService } from 'src/services/search.service'; import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, beforeEach, vitest } from 'vitest'; import { Mocked, beforeEach, vitest } from 'vitest';
vitest.useFakeTimers(); vitest.useFakeTimers();
describe(SearchService.name, () => { describe(SearchService.name, () => {
let sut: SearchService; let sut: SearchService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let machineMock: Mocked<IMachineLearningRepository>;
let personMock: Mocked<IPersonRepository>; let personMock: Mocked<IPersonRepository>;
let searchMock: Mocked<ISearchRepository>; let searchMock: Mocked<ISearchRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, assetMock, personMock, searchMock } = newTestService(SearchService)); assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
personMock = newPersonRepositoryMock();
searchMock = newSearchRepositoryMock();
partnerMock = newPartnerRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SearchService(
configMock,
systemMock,
machineMock,
personMock,
searchMock,
assetMock,
partnerMock,
loggerMock,
);
}); });
it('should work', () => { it('should work', () => {
+24 -7
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { PersonResponseDto } from 'src/dtos/person.dto'; import { PersonResponseDto } from 'src/dtos/person.dto';
@@ -16,13 +16,34 @@ import {
} from 'src/dtos/search.dto'; } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetOrder } from 'src/enum'; import { AssetOrder } from 'src/enum';
import { SearchExploreItem } from 'src/interfaces/search.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getMyPartnerIds } from 'src/utils/asset.util'; import { getMyPartnerIds } from 'src/utils/asset.util';
import { isSmartSearchEnabled } from 'src/utils/misc'; import { isSmartSearchEnabled } from 'src/utils/misc';
@Injectable() @Injectable()
export class SearchService extends BaseService { export class SearchService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SearchService.name);
}
async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> { async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
} }
@@ -87,11 +108,7 @@ export class SearchService extends BaseService {
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth);
const embedding = await this.machineLearningRepository.encodeText( const embedding = await this.machineLearning.encodeText(machineLearning.url, dto.query, machineLearning.clip);
machineLearning.url,
dto.query,
machineLearning.clip,
);
const page = dto.page ?? 1; const page = dto.page ?? 1;
const size = dto.size || 100; const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart( const { hasNextPage, items } = await this.searchRepository.searchSmart(
+25 -4
View File
@@ -1,20 +1,41 @@
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerService } from 'src/services/server.service'; import { ServerService } from 'src/services/server.service';
import { newTestService } from 'test/utils'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(ServerService.name, () => { describe(ServerService.name, () => {
let sut: ServerService; let sut: ServerService;
let configMock: Mocked<IConfigRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let serverInfoMock: Mocked<IServerInfoRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, storageMock, systemMock, userMock } = newTestService(ServerService)); configMock = newConfigRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
sut = new ServerService(configMock, userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock);
}); });
it('should work', () => { it('should work', () => {
+21 -2
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { getBuildMetadata, getServerLicensePublicKey } from 'src/config';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
@@ -15,7 +15,13 @@ import {
UsageByUserDto, UsageByUserDto,
} from 'src/dtos/server.dto'; } from 'src/dtos/server.dto';
import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { asHumanReadable } from 'src/utils/bytes'; import { asHumanReadable } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
@@ -23,6 +29,19 @@ import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchE
@Injectable() @Injectable()
export class ServerService extends BaseService { export class ServerService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(ServerService.name);
}
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
async onBootstrap(): Promise<void> { async onBootstrap(): Promise<void> {
const featureFlags = await this.getFeatures(); const featureFlags = await this.getFeatures();
+10 -4
View File
@@ -1,21 +1,27 @@
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISessionRepository } from 'src/interfaces/session.interface';
import { SessionService } from 'src/services/session.service'; import { SessionService } from 'src/services/session.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub'; import { sessionStub } from 'test/fixtures/session.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe('SessionService', () => { describe('SessionService', () => {
let sut: SessionService; let sut: SessionService;
let accessMock: Mocked<IAccessRepositoryMock>; let accessMock: Mocked<IAccessRepositoryMock>;
let loggerMock: Mocked<ILoggerRepository>;
let sessionMock: Mocked<ISessionRepository>; let sessionMock: Mocked<ISessionRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, sessionMock } = newTestService(SessionService)); accessMock = newAccessRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sessionMock = newSessionRepositoryMock();
sut = new SessionService(accessMock, loggerMock, sessionMock);
}); });
it('should be defined', () => { it('should be defined', () => {
+14 -4
View File
@@ -1,14 +1,24 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
@Injectable() @Injectable()
export class SessionService extends BaseService { export class SessionService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
) {
this.logger.setContext(SessionService.name);
}
async handleCleanup() { async handleCleanup() {
const sessions = await this.sessionRepository.search({ const sessions = await this.sessionRepository.search({
updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(), updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(),
@@ -34,7 +44,7 @@ export class SessionService extends BaseService {
} }
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] });
await this.sessionRepository.delete(id); await this.sessionRepository.delete(id);
} }
+68 -50
View File
@@ -3,24 +3,42 @@ import _ from 'lodash';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(SharedLinkService.name, () => { describe(SharedLinkService.name, () => {
let sut: SharedLinkService; let sut: SharedLinkService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let sharedLinkMock: Mocked<ISharedLinkRepository>; let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let shareMock: Mocked<ISharedLinkRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let logMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService)); accessMock = newAccessRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
logMock = newLoggerRepositoryMock();
sut = new SharedLinkService(accessMock, configMock, cryptoMock, logMock, shareMock, systemMock);
}); });
it('should work', () => { it('should work', () => {
@@ -29,55 +47,55 @@ describe(SharedLinkService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('should return all shared links for a user', async () => { it('should return all shared links for a user', async () => {
sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([ await expect(sut.getAll(authStub.user1)).resolves.toEqual([
sharedLinkResponseStub.expired, sharedLinkResponseStub.expired,
sharedLinkResponseStub.valid, sharedLinkResponseStub.valid,
]); ]);
expect(sharedLinkMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
}); });
}); });
describe('getMine', () => { describe('getMine', () => {
it('should only work for a public user', async () => { it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException); await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(sharedLinkMock.get).not.toHaveBeenCalled(); expect(shareMock.get).not.toHaveBeenCalled();
}); });
it('should return the shared link for the public user', async () => { it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink; const authDto = authStub.adminSharedLink;
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
it('should not return metadata', async () => { it('should not return metadata', async () => {
const authDto = authStub.adminSharedLinkNoExif; const authDto = authStub.adminSharedLinkNoExif;
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
it('should throw an error for an password protected shared link', async () => { it('should throw an error for an password protected shared link', async () => {
const authDto = authStub.adminSharedLink; const authDto = authStub.adminSharedLink;
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired); shareMock.get.mockResolvedValue(sharedLinkStub.passwordRequired);
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
}); });
describe('get', () => { describe('get', () => {
it('should throw an error for an invalid shared link', async () => { it('should throw an error for an invalid shared link', async () => {
sharedLinkMock.get.mockResolvedValue(null); shareMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(sharedLinkMock.update).not.toHaveBeenCalled(); expect(shareMock.update).not.toHaveBeenCalled();
}); });
it('should get a shared link by id', async () => { it('should get a shared link by id', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
}); });
}); });
@@ -108,7 +126,7 @@ describe(SharedLinkService.name, () => {
it('should create an album shared link', async () => { it('should create an album shared link', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.valid); shareMock.create.mockResolvedValue(sharedLinkStub.valid);
await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
@@ -116,7 +134,7 @@ describe(SharedLinkService.name, () => {
authStub.admin.user.id, authStub.admin.user.id,
new Set([albumStub.oneAsset.id]), new Set([albumStub.oneAsset.id]),
); );
expect(sharedLinkMock.create).toHaveBeenCalledWith({ expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.ALBUM, type: SharedLinkType.ALBUM,
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
albumId: albumStub.oneAsset.id, albumId: albumStub.oneAsset.id,
@@ -132,7 +150,7 @@ describe(SharedLinkService.name, () => {
it('should create an individual shared link', async () => { it('should create an individual shared link', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
@@ -146,7 +164,7 @@ describe(SharedLinkService.name, () => {
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
expect(sharedLinkMock.create).toHaveBeenCalledWith({ expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
albumId: null, albumId: null,
@@ -162,7 +180,7 @@ describe(SharedLinkService.name, () => {
it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { it('should create a shared link with allowDownload set to false when showMetadata is false', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
@@ -176,7 +194,7 @@ describe(SharedLinkService.name, () => {
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([assetStub.image.id]),
); );
expect(sharedLinkMock.create).toHaveBeenCalledWith({ expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
albumId: null, albumId: null,
@@ -193,18 +211,18 @@ describe(SharedLinkService.name, () => {
describe('update', () => { describe('update', () => {
it('should throw an error for an invalid shared link', async () => { it('should throw an error for an invalid shared link', async () => {
sharedLinkMock.get.mockResolvedValue(null); shareMock.get.mockResolvedValue(null);
await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException); await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(sharedLinkMock.update).not.toHaveBeenCalled(); expect(shareMock.update).not.toHaveBeenCalled();
}); });
it('should update a shared link', async () => { it('should update a shared link', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); shareMock.get.mockResolvedValue(sharedLinkStub.valid);
sharedLinkMock.update.mockResolvedValue(sharedLinkStub.valid); shareMock.update.mockResolvedValue(sharedLinkStub.valid);
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(sharedLinkMock.update).toHaveBeenCalledWith({ expect(shareMock.update).toHaveBeenCalledWith({
id: sharedLinkStub.valid.id, id: sharedLinkStub.valid.id,
userId: authStub.user1.user.id, userId: authStub.user1.user.id,
allowDownload: false, allowDownload: false,
@@ -214,31 +232,31 @@ describe(SharedLinkService.name, () => {
describe('remove', () => { describe('remove', () => {
it('should throw an error for an invalid shared link', async () => { it('should throw an error for an invalid shared link', async () => {
sharedLinkMock.get.mockResolvedValue(null); shareMock.get.mockResolvedValue(null);
await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(sharedLinkMock.update).not.toHaveBeenCalled(); expect(shareMock.update).not.toHaveBeenCalled();
}); });
it('should remove a key', async () => { it('should remove a key', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await sut.remove(authStub.user1, sharedLinkStub.valid.id); await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(sharedLinkMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid); expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
}); });
}); });
describe('addAssets', () => { describe('addAssets', () => {
it('should not work on album shared links', async () => { it('should not work on album shared links', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
}); });
it('should add assets to a shared link', async () => { it('should add assets to a shared link', async () => {
sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); shareMock.create.mockResolvedValue(sharedLinkStub.individual);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3']));
await expect( await expect(
@@ -250,7 +268,7 @@ describe(SharedLinkService.name, () => {
]); ]);
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1);
expect(sharedLinkMock.update).toHaveBeenCalledWith({ expect(shareMock.update).toHaveBeenCalledWith({
...sharedLinkStub.individual, ...sharedLinkStub.individual,
assets: [assetStub.image, { id: 'asset-3' }], assets: [assetStub.image, { id: 'asset-3' }],
}); });
@@ -259,15 +277,15 @@ describe(SharedLinkService.name, () => {
describe('removeAssets', () => { describe('removeAssets', () => {
it('should not work on album shared links', async () => { it('should not work on album shared links', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
}); });
it('should remove assets from a shared link', async () => { it('should remove assets from a shared link', async () => {
sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await expect( await expect(
sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }),
@@ -276,29 +294,29 @@ describe(SharedLinkService.name, () => {
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
]); ]);
expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
}); });
}); });
describe('getMetadataTags', () => { describe('getMetadataTags', () => {
it('should return null when auth is not a shared link', async () => { it('should return null when auth is not a shared link', async () => {
await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
expect(sharedLinkMock.get).not.toHaveBeenCalled(); expect(shareMock.get).not.toHaveBeenCalled();
}); });
it('should return null when shared link has a password', async () => { it('should return null when shared link has a password', async () => {
await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null);
expect(sharedLinkMock.get).not.toHaveBeenCalled(); expect(shareMock.get).not.toHaveBeenCalled();
}); });
it('should return metadata tags', async () => { it('should return metadata tags', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.individual); shareMock.get.mockResolvedValue(sharedLinkStub.individual);
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '1 shared photos & videos', description: '1 shared photos & videos',
imageUrl: `${DEFAULT_EXTERNAL_DOMAIN}/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, imageUrl: `${DEFAULT_EXTERNAL_DOMAIN}/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
title: 'Public Share', title: 'Public Share',
}); });
expect(sharedLinkMock.get).toHaveBeenCalled(); expect(shareMock.get).toHaveBeenCalled();
}); });
}); });
}); });
+29 -11
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto';
@@ -14,14 +14,32 @@ import {
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Permission, SharedLinkType } from 'src/enum'; import { Permission, SharedLinkType } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access'; import { checkAccess, requireAccess } from 'src/utils/access';
import { OpenGraphTags } from 'src/utils/misc'; import { OpenGraphTags } from 'src/utils/misc';
@Injectable() @Injectable()
export class SharedLinkService extends BaseService { export class SharedLinkService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SharedLinkService.name);
}
getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> { getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> {
return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); return this.repository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link)));
} }
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> { async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
@@ -49,7 +67,7 @@ export class SharedLinkService extends BaseService {
if (!dto.albumId) { if (!dto.albumId) {
throw new BadRequestException('Invalid albumId'); throw new BadRequestException('Invalid albumId');
} }
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] });
break; break;
} }
@@ -58,13 +76,13 @@ export class SharedLinkService extends BaseService {
throw new BadRequestException('Invalid assetIds'); throw new BadRequestException('Invalid assetIds');
} }
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); await requireAccess(this.access, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds });
break; break;
} }
} }
const sharedLink = await this.sharedLinkRepository.create({ const sharedLink = await this.repository.create({
key: this.cryptoRepository.randomBytes(50), key: this.cryptoRepository.randomBytes(50),
userId: auth.user.id, userId: auth.user.id,
type: dto.type, type: dto.type,
@@ -83,7 +101,7 @@ export class SharedLinkService extends BaseService {
async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) { async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) {
await this.findOrFail(auth.user.id, id); await this.findOrFail(auth.user.id, id);
const sharedLink = await this.sharedLinkRepository.update({ const sharedLink = await this.repository.update({
id, id,
userId: auth.user.id, userId: auth.user.id,
description: dto.description, description: dto.description,
@@ -98,12 +116,12 @@ export class SharedLinkService extends BaseService {
async remove(auth: AuthDto, id: string): Promise<void> { async remove(auth: AuthDto, id: string): Promise<void> {
const sharedLink = await this.findOrFail(auth.user.id, id); const sharedLink = await this.findOrFail(auth.user.id, id);
await this.sharedLinkRepository.remove(sharedLink); await this.repository.remove(sharedLink);
} }
// TODO: replace `userId` with permissions and access control checks // TODO: replace `userId` with permissions and access control checks
private async findOrFail(userId: string, id: string) { private async findOrFail(userId: string, id: string) {
const sharedLink = await this.sharedLinkRepository.get(userId, id); const sharedLink = await this.repository.get(userId, id);
if (!sharedLink) { if (!sharedLink) {
throw new BadRequestException('Shared link not found'); throw new BadRequestException('Shared link not found');
} }
@@ -119,7 +137,7 @@ export class SharedLinkService extends BaseService {
const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id)); const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id));
const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId)); const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId));
const allowedAssetIds = await checkAccess(this.accessRepository, { const allowedAssetIds = await checkAccess(this.access, {
auth, auth,
permission: Permission.ASSET_SHARE, permission: Permission.ASSET_SHARE,
ids: notPresentAssetIds, ids: notPresentAssetIds,
@@ -143,7 +161,7 @@ export class SharedLinkService extends BaseService {
sharedLink.assets.push({ id: assetId } as AssetEntity); sharedLink.assets.push({ id: assetId } as AssetEntity);
} }
await this.sharedLinkRepository.update(sharedLink); await this.repository.update(sharedLink);
return results; return results;
} }
@@ -167,7 +185,7 @@ export class SharedLinkService extends BaseService {
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId); sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId);
} }
await this.sharedLinkRepository.update(sharedLink); await this.repository.update(sharedLink);
return results; return results;
} }
+41 -11
View File
@@ -1,6 +1,9 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -8,20 +11,47 @@ import { SmartInfoService } from 'src/services/smart-info.service';
import { getCLIPModelInfo } from 'src/utils/misc'; import { getCLIPModelInfo } from 'src/utils/misc';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(SmartInfoService.name, () => { describe(SmartInfoService.name, () => {
let sut: SmartInfoService; let sut: SmartInfoService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>; let configMock: Mocked<IConfigRepository>;
let machineLearningMock: Mocked<IMachineLearningRepository>;
let searchMock: Mocked<ISearchRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>;
let searchMock: Mocked<ISearchRepository>;
let machineMock: Mocked<IMachineLearningRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, assetMock, jobMock, machineLearningMock, searchMock, systemMock } = newTestService(SmartInfoService)); assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock();
jobMock = newJobRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SmartInfoService(
assetMock,
configMock,
databaseMock,
jobMock,
machineMock,
searchMock,
systemMock,
loggerMock,
);
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
}); });
@@ -283,7 +313,7 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled();
}); });
it('should skip assets without a resize path', async () => { it('should skip assets without a resize path', async () => {
@@ -292,15 +322,15 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED);
expect(searchMock.upsert).not.toHaveBeenCalled(); expect(searchMock.upsert).not.toHaveBeenCalled();
expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled();
}); });
it('should save the returned objects', async () => { it('should save the returned objects', async () => {
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( expect(machineMock.encodeImage).toHaveBeenCalledWith(
'http://immich-machine-learning:3003', 'http://immich-machine-learning:3003',
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
@@ -313,7 +343,7 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled();
expect(searchMock.upsert).not.toHaveBeenCalled(); expect(searchMock.upsert).not.toHaveBeenCalled();
}); });
}); });
+29 -9
View File
@@ -1,17 +1,23 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { import {
IBaseJob, IBaseJob,
IEntityJob, IEntityJob,
IJobRepository,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobName, JobName,
JobStatus, JobStatus,
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
@@ -19,6 +25,20 @@ import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class SmartInfoService extends BaseService { export class SmartInfoService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@Inject(ISearchRepository) private repository: ISearchRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SmartInfoService.name);
}
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) { async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== 'microservices') { if (app !== 'microservices') {
@@ -52,7 +72,7 @@ export class SmartInfoService extends BaseService {
await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => { await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => {
const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName); const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
const dbDimSize = await this.searchRepository.getDimensionSize(); const dbDimSize = await this.repository.getDimensionSize();
this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`); this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`);
const modelChange = const modelChange =
@@ -73,10 +93,10 @@ export class SmartInfoService extends BaseService {
`Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`, `Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`,
); );
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`); this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
await this.searchRepository.setDimensionSize(dimSize); await this.repository.setDimensionSize(dimSize);
this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`); this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`);
} else { } else {
await this.searchRepository.deleteAllSearchEmbeddings(); await this.repository.deleteAllSearchEmbeddings();
} }
if (!isPaused) { if (!isPaused) {
@@ -92,7 +112,7 @@ export class SmartInfoService extends BaseService {
} }
if (force) { if (force) {
await this.searchRepository.deleteAllSearchEmbeddings(); await this.repository.deleteAllSearchEmbeddings();
} }
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
@@ -130,7 +150,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const embedding = await this.machineLearningRepository.encodeImage( const embedding = await this.machineLearning.encodeImage(
machineLearning.url, machineLearning.url,
previewFile.path, previewFile.path,
machineLearning.clip, machineLearning.clip,
@@ -141,7 +161,7 @@ export class SmartInfoService extends BaseService {
await this.databaseRepository.wait(DatabaseLock.CLIPDimSize); await this.databaseRepository.wait(DatabaseLock.CLIPDimSize);
} }
await this.searchRepository.upsert(asset.id, embedding); await this.repository.upsert(asset.id, embedding);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
+16 -8
View File
@@ -1,13 +1,21 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
@Injectable() @Injectable()
export class StackService extends BaseService { export class StackService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IStackRepository) private stackRepository: IStackRepository,
) {}
async search(auth: AuthDto, dto: StackSearchDto): Promise<StackResponseDto[]> { async search(auth: AuthDto, dto: StackSearchDto): Promise<StackResponseDto[]> {
const stacks = await this.stackRepository.search({ const stacks = await this.stackRepository.search({
ownerId: auth.user.id, ownerId: auth.user.id,
@@ -18,7 +26,7 @@ export class StackService extends BaseService {
} }
async create(auth: AuthDto, dto: StackCreateDto): Promise<StackResponseDto> { async create(auth: AuthDto, dto: StackCreateDto): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds });
@@ -28,13 +36,13 @@ export class StackService extends BaseService {
} }
async get(auth: AuthDto, id: string): Promise<StackResponseDto> { async get(auth: AuthDto, id: string): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.STACK_READ, ids: [id] });
const stack = await this.findOrFail(id); const stack = await this.findOrFail(id);
return mapStack(stack, { auth }); return mapStack(stack, { auth });
} }
async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise<StackResponseDto> { async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.STACK_UPDATE, ids: [id] });
const stack = await this.findOrFail(id); const stack = await this.findOrFail(id);
if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) {
throw new BadRequestException('Primary asset must be in the stack'); throw new BadRequestException('Primary asset must be in the stack');
@@ -48,13 +56,13 @@ export class StackService extends BaseService {
} }
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] });
await this.stackRepository.delete(id); await this.stackRepository.delete(id);
await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id }); await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id });
} }
async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> { async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: dto.ids }); await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids });
await this.stackRepository.deleteAll(dto.ids); await this.stackRepository.deleteAll(dto.ids);
await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id }); await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id });
} }
@@ -4,9 +4,13 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/enum'; import { AssetPathType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
@@ -14,30 +18,66 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(StorageTemplateService.name, () => { describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService; let sut: StorageTemplateService;
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>; let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let moveMock: Mocked<IMoveRepository>; let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
beforeEach(() => { beforeEach(() => {
({ sut, albumMock, assetMock, cryptoMock, moveMock, storageMock, systemMock, userMock } = assetMock = newAssetRepositoryMock();
newTestService(StorageTemplateService)); albumMock = newAlbumRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } }); systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } });
sut = new StorageTemplateService(
albumMock,
assetMock,
configMock,
systemMock,
moveMock,
personMock,
storageMock,
userMock,
cryptoMock,
databaseMock,
loggerMock,
);
sut.onConfigUpdate({ newConfig: defaults }); sut.onConfigUpdate({ newConfig: defaults });
}); });
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import handlebar from 'handlebars'; import handlebar from 'handlebars';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import path from 'node:path'; import path from 'node:path';
@@ -16,9 +16,19 @@ import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getLivePhotoMotionFilename } from 'src/utils/file'; import { getLivePhotoMotionFilename } from 'src/utils/file';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@@ -37,6 +47,7 @@ interface RenderMetadata {
@Injectable() @Injectable()
export class StorageTemplateService extends BaseService { export class StorageTemplateService extends BaseService {
private storageCore: StorageCore;
private _template: { private _template: {
compiled: HandlebarsTemplateDelegate<any>; compiled: HandlebarsTemplateDelegate<any>;
raw: string; raw: string;
@@ -50,6 +61,33 @@ export class StorageTemplateService extends BaseService {
return this._template; return this._template;
} }
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(StorageTemplateService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
@OnEvent({ name: 'config.update', server: true }) @OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { onConfigUpdate({ newConfig }: ArgOf<'config.update'>) {
const template = newConfig.storageTemplate.template; const template = newConfig.storageTemplate.template;
+16 -4
View File
@@ -1,21 +1,33 @@
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(StorageService.name, () => { describe(StorageService.name, () => {
let sut: StorageService; let sut: StorageService;
let configMock: Mocked<IConfigRepository>; let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, configMock, storageMock, systemMock } = newTestService(StorageService)); configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
storageMock = newStorageRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
sut = new StorageService(configMock, databaseMock, storageMock, loggerMock, systemMock);
}); });
it('should work', () => { it('should work', () => {
+19 -6
View File
@@ -1,23 +1,36 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { join } from 'node:path'; import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ImmichStartupError } from 'src/utils/events'; import { ImmichStartupError } from 'src/utils/events';
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`; const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
@Injectable() @Injectable()
export class StorageService extends BaseService { export class StorageService {
constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository,
) {
this.logger.setContext(StorageService.name);
}
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
async onBootstrap() { async onBootstrap() {
const envData = this.configRepository.getEnv(); const envData = this.configRepository.getEnv();
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags = (await this.systemMetadataRepository.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
const enabled = flags.mountFiles ?? false; const enabled = flags.mountFiles ?? false;
this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`); this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`);
@@ -36,7 +49,7 @@ export class StorageService extends BaseService {
if (!flags.mountFiles) { if (!flags.mountFiles) {
flags.mountFiles = true; flags.mountFiles = true;
await this.systemMetadataRepository.set(SystemMetadataKey.SYSTEM_FLAGS, flags); await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags);
this.logger.log('Successfully enabled system mount folders checks'); this.logger.log('Successfully enabled system mount folders checks');
} }
+12 -4
View File
@@ -1,5 +1,6 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
@@ -7,7 +8,10 @@ import { SyncService } from 'src/services/sync.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { partnerStub } from 'test/fixtures/partner.stub'; import { partnerStub } from 'test/fixtures/partner.stub';
import { newTestService } from 'test/utils'; import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const untilDate = new Date(2024); const untilDate = new Date(2024);
@@ -15,13 +19,17 @@ const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: tr
describe(SyncService.name, () => { describe(SyncService.name, () => {
let sut: SyncService; let sut: SyncService;
let accessMock: Mocked<IAccessRepository>;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let auditMock: Mocked<IAuditRepository>;
let partnerMock: Mocked<IPartnerRepository>; let partnerMock: Mocked<IPartnerRepository>;
let auditMock: Mocked<IAuditRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, assetMock, auditMock, partnerMock } = newTestService(SyncService)); partnerMock = newPartnerRepositoryMock();
assetMock = newAssetRepositoryMock();
accessMock = newAccessRepositoryMock();
auditMock = newAuditRepositoryMock();
sut = new SyncService(accessMock, assetMock, partnerMock, auditMock);
}); });
it('should exist', () => { it('should exist', () => {
+15 -4
View File
@@ -1,21 +1,32 @@
import { Inject } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto';
import { DatabaseAction, EntityType, Permission } from 'src/enum'; import { DatabaseAction, EntityType, Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util'; import { getMyPartnerIds } from 'src/utils/asset.util';
import { setIsEqual } from 'src/utils/set'; import { setIsEqual } from 'src/utils/set';
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
export class SyncService extends BaseService { export class SyncService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IAuditRepository) private auditRepository: IAuditRepository,
) {}
async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> { async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
// mobile implementation is faster if this is a single id // mobile implementation is faster if this is a single id
const userId = dto.userId || auth.user.id; const userId = dto.userId || auth.user.id;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] });
const assets = await this.assetRepository.getAllForUserFullSync({ const assets = await this.assetRepository.getAllForUserFullSync({
ownerId: userId, ownerId: userId,
updatedUntil: dto.updatedUntil, updatedUntil: dto.updatedUntil,
@@ -39,7 +50,7 @@ export class SyncService extends BaseService {
return FULL_SYNC; return FULL_SYNC;
} }
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds });
const limit = 10_000; const limit = 10_000;
const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds }); const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds });
@@ -18,8 +18,10 @@ import { QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemConfigService } from 'src/services/system-config.service'; import { SystemConfigService } from 'src/services/system-config.service';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { DeepPartial } from 'typeorm'; import { DeepPartial } from 'typeorm';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
@@ -187,14 +189,18 @@ const updatedConfig = Object.freeze<SystemConfig>({
describe(SystemConfigService.name, () => { describe(SystemConfigService.name, () => {
let sut: SystemConfigService; let sut: SystemConfigService;
let configMock: Mocked<IConfigRepository>; let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService)); configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SystemConfigService(configMock, eventMock, systemMock, loggerMock);
}); });
it('should work', () => { it('should work', () => {
+22 -5
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer'; import { instanceToPlain } from 'class-transformer';
import _ from 'lodash'; import _ from 'lodash';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
@@ -14,13 +14,27 @@ import {
} from 'src/constants'; } from 'src/constants';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
import { ArgOf } from 'src/interfaces/event.interface'; import { LogLevel } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { clearConfigCache } from 'src/utils/config'; import { clearConfigCache } from 'src/utils/config';
import { toPlainObject } from 'src/utils/object'; import { toPlainObject } from 'src/utils/object';
@Injectable() @Injectable()
export class SystemConfigService extends BaseService { export class SystemConfigService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SystemConfigService.name);
}
@OnEvent({ name: 'app.bootstrap', priority: -100 }) @OnEvent({ name: 'app.bootstrap', priority: -100 })
async onBootstrap() { async onBootstrap() {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
@@ -38,7 +52,7 @@ export class SystemConfigService extends BaseService {
@OnEvent({ name: 'config.update', server: true }) @OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) { onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) {
const { logLevel: envLevel } = this.configRepository.getEnv(); const envLevel = this.getEnvLogLevel();
const configLevel = logging.enabled ? logging.level : false; const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ?? configLevel; const level = envLevel ?? configLevel;
this.logger.setLogLevel(level); this.logger.setLogLevel(level);
@@ -49,8 +63,7 @@ export class SystemConfigService extends BaseService {
@OnEvent({ name: 'config.validate' }) @OnEvent({ name: 'config.validate' })
onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) {
const { logLevel } = this.configRepository.getEnv(); if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) {
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && logLevel) {
throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.');
} }
} }
@@ -96,4 +109,8 @@ export class SystemConfigService extends BaseService {
const { theme } = await this.getConfig({ withCache: false }); const { theme } = await this.getConfig({ withCache: false });
return theme.customCss; return theme.customCss;
} }
private getEnvLogLevel() {
return process.env.IMMICH_LOG_LEVEL as LogLevel;
}
} }
@@ -1,15 +1,16 @@
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemMetadataService } from 'src/services/system-metadata.service'; import { SystemMetadataService } from 'src/services/system-metadata.service';
import { newTestService } from 'test/utils'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(SystemMetadataService.name, () => { describe(SystemMetadataService.name, () => {
let sut: SystemMetadataService; let sut: SystemMetadataService;
let systemMock: Mocked<ISystemMetadataRepository>; let metadataMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, systemMock } = newTestService(SystemMetadataService)); metadataMock = newSystemMetadataRepositoryMock();
sut = new SystemMetadataService(metadataMock);
}); });
it('should work', () => { it('should work', () => {
@@ -19,12 +20,12 @@ describe(SystemMetadataService.name, () => {
describe('updateAdminOnboarding', () => { describe('updateAdminOnboarding', () => {
it('should update isOnboarded to true', async () => { it('should update isOnboarded to true', async () => {
await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined();
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
}); });
it('should update isOnboarded to false', async () => { it('should update isOnboarded to false', async () => {
await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined();
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false });
}); });
}); });
}); });
@@ -1,27 +1,29 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { import {
AdminOnboardingResponseDto, AdminOnboardingResponseDto,
AdminOnboardingUpdateDto, AdminOnboardingUpdateDto,
ReverseGeocodingStateResponseDto, ReverseGeocodingStateResponseDto,
} from 'src/dtos/system-metadata.dto'; } from 'src/dtos/system-metadata.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@Injectable() @Injectable()
export class SystemMetadataService extends BaseService { export class SystemMetadataService {
constructor(@Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository) {}
async getAdminOnboarding(): Promise<AdminOnboardingResponseDto> { async getAdminOnboarding(): Promise<AdminOnboardingResponseDto> {
const value = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); const value = await this.repository.get(SystemMetadataKey.ADMIN_ONBOARDING);
return { isOnboarded: false, ...value }; return { isOnboarded: false, ...value };
} }
async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise<void> { async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise<void> {
await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { await this.repository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
isOnboarded: dto.isOnboarded, isOnboarded: dto.isOnboarded,
}); });
} }
async getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> { async getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
const value = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); const value = await this.repository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
return { lastUpdate: null, lastImportFileName: null, ...value }; return { lastUpdate: null, lastImportFileName: null, ...value };
} }
} }
+9 -4
View File
@@ -1,21 +1,26 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITagRepository } from 'src/interfaces/tag.interface';
import { TagService } from 'src/services/tag.service'; import { TagService } from 'src/services/tag.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(TagService.name, () => { describe(TagService.name, () => {
let sut: TagService; let sut: TagService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let eventMock: Mocked<IEventRepository>;
let tagMock: Mocked<ITagRepository>; let tagMock: Mocked<ITagRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, tagMock } = newTestService(TagService)); accessMock = newAccessRepositoryMock();
eventMock = newEventRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new TagService(accessMock, eventMock, tagMock);
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
}); });
+31 -24
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
@@ -12,22 +12,29 @@ import {
} from 'src/dtos/tag.dto'; } from 'src/dtos/tag.dto';
import { TagEntity } from 'src/entities/tag.entity'; import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { AssetTagItem } from 'src/interfaces/tag.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access'; import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util'; import { addAssets, removeAssets } from 'src/utils/asset.util';
import { upsertTags } from 'src/utils/tag'; import { upsertTags } from 'src/utils/tag';
@Injectable() @Injectable()
export class TagService extends BaseService { export class TagService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ITagRepository) private repository: ITagRepository,
) {}
async getAll(auth: AuthDto) { async getAll(auth: AuthDto) {
const tags = await this.tagRepository.getAll(auth.user.id); const tags = await this.repository.getAll(auth.user.id);
return tags.map((tag) => mapTag(tag)); return tags.map((tag) => mapTag(tag));
} }
async get(auth: AuthDto, id: string): Promise<TagResponseDto> { async get(auth: AuthDto, id: string): Promise<TagResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] });
const tag = await this.findOrFail(id); const tag = await this.findOrFail(id);
return mapTag(tag); return mapTag(tag);
} }
@@ -35,8 +42,8 @@ export class TagService extends BaseService {
async create(auth: AuthDto, dto: TagCreateDto) { async create(auth: AuthDto, dto: TagCreateDto) {
let parent: TagEntity | undefined; let parent: TagEntity | undefined;
if (dto.parentId) { if (dto.parentId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
parent = (await this.tagRepository.get(dto.parentId)) || undefined; parent = (await this.repository.get(dto.parentId)) || undefined;
if (!parent) { if (!parent) {
throw new BadRequestException('Tag not found'); throw new BadRequestException('Tag not found');
} }
@@ -44,41 +51,41 @@ export class TagService extends BaseService {
const userId = auth.user.id; const userId = auth.user.id;
const value = parent ? `${parent.value}/${dto.name}` : dto.name; const value = parent ? `${parent.value}/${dto.name}` : dto.name;
const duplicate = await this.tagRepository.getByValue(userId, value); const duplicate = await this.repository.getByValue(userId, value);
if (duplicate) { if (duplicate) {
throw new BadRequestException(`A tag with that name already exists`); throw new BadRequestException(`A tag with that name already exists`);
} }
const tag = await this.tagRepository.create({ userId, value, parent }); const tag = await this.repository.create({ userId, value, parent });
return mapTag(tag); return mapTag(tag);
} }
async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> { async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
const { color } = dto; const { color } = dto;
const tag = await this.tagRepository.update({ id, color }); const tag = await this.repository.update({ id, color });
return mapTag(tag); return mapTag(tag);
} }
async upsert(auth: AuthDto, dto: TagUpsertDto) { async upsert(auth: AuthDto, dto: TagUpsertDto) {
const tags = await upsertTags(this.tagRepository, { userId: auth.user.id, tags: dto.tags }); const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags });
return tags.map((tag) => mapTag(tag)); return tags.map((tag) => mapTag(tag));
} }
async remove(auth: AuthDto, id: string): Promise<void> { async remove(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_DELETE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] });
// TODO sync tag changes for affected assets // TODO sync tag changes for affected assets
await this.tagRepository.delete(id); await this.repository.delete(id);
} }
async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> { async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
const [tagIds, assetIds] = await Promise.all([ const [tagIds, assetIds] = await Promise.all([
checkAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
checkAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
]); ]);
const items: AssetTagItem[] = []; const items: AssetTagItem[] = [];
@@ -88,7 +95,7 @@ export class TagService extends BaseService {
} }
} }
const results = await this.tagRepository.upsertAssetIds(items); const results = await this.repository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetId))) { for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.eventRepository.emit('asset.tag', { assetId }); await this.eventRepository.emit('asset.tag', { assetId });
} }
@@ -97,11 +104,11 @@ export class TagService extends BaseService {
} }
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results = await addAssets( const results = await addAssets(
auth, auth,
{ access: this.accessRepository, bulk: this.tagRepository }, { access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids }, { parentId: id, assetIds: dto.ids },
); );
@@ -115,11 +122,11 @@ export class TagService extends BaseService {
} }
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results = await removeAssets( const results = await removeAssets(
auth, auth,
{ access: this.accessRepository, bulk: this.tagRepository }, { access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE }, { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE },
); );
@@ -133,12 +140,12 @@ export class TagService extends BaseService {
} }
async handleTagCleanup() { async handleTagCleanup() {
await this.tagRepository.deleteEmptyTags(); await this.repository.deleteEmptyTags();
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
private async findOrFail(id: string) { private async findOrFail(id: string) {
const tag = await this.tagRepository.get(id); const tag = await this.repository.get(id);
if (!tag) { if (!tag) {
throw new BadRequestException('Tag not found'); throw new BadRequestException('Tag not found');
} }
+10 -5
View File
@@ -1,20 +1,25 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { TimelineService } from 'src/services/timeline.service'; import { TimelineService } from 'src/services/timeline.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(TimelineService.name, () => { describe(TimelineService.name, () => {
let sut: TimelineService; let sut: TimelineService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let partnerMock: Mocked<IPartnerRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, assetMock } = newTestService(TimelineService)); accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
partnerMock = newPartnerRepositoryMock();
sut = new TimelineService(accessMock, assetMock, partnerMock);
}); });
describe('getTimeBuckets', () => { describe('getTimeBuckets', () => {
+18 -10
View File
@@ -1,18 +1,26 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException, Inject } from '@nestjs/common';
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { TimeBucketOptions } from 'src/interfaces/asset.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { BaseService } from 'src/services/base.service'; import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util'; import { getMyPartnerIds } from 'src/utils/asset.util';
export class TimelineService extends BaseService { export class TimelineService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private repository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) {}
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> { async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
await this.timeBucketChecks(auth, dto); await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
return this.assetRepository.getTimeBuckets(timeBucketOptions);
return this.repository.getTimeBuckets(timeBucketOptions);
} }
async getTimeBucket( async getTimeBucket(
@@ -21,7 +29,7 @@ export class TimelineService extends BaseService {
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> { ): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
await this.timeBucketChecks(auth, dto); await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); const assets = await this.repository.getTimeBucket(dto.timeBucket, timeBucketOptions);
return !auth.sharedLink || auth.sharedLink?.showExif return !auth.sharedLink || auth.sharedLink?.showExif
? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) ? assets.map((asset) => mapAsset(asset, { withStack: true, auth }))
: assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth }));
@@ -48,20 +56,20 @@ export class TimelineService extends BaseService {
private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) {
if (dto.albumId) { if (dto.albumId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
} else { } else {
dto.userId = dto.userId || auth.user.id; dto.userId = dto.userId || auth.user.id;
} }
if (dto.userId) { if (dto.userId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] });
if (dto.isArchived !== false) { if (dto.isArchived !== false) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); await requireAccess(this.access, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] });
} }
} }
if (dto.tagId) { if (dto.tagId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] });
} }
if (dto.withPartners) { if (dto.withPartners) {
+16 -4
View File
@@ -1,25 +1,37 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface'; import { ITrashRepository } from 'src/interfaces/trash.interface';
import { TrashService } from 'src/services/trash.service'; import { TrashService } from 'src/services/trash.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(TrashService.name, () => { describe(TrashService.name, () => {
let sut: TrashService; let sut: TrashService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let trashMock: Mocked<ITrashRepository>; let trashMock: Mocked<ITrashRepository>;
let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
beforeEach(() => { beforeEach(() => {
({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService)); accessMock = newAccessRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
trashMock = newTrashRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new TrashService(accessMock, eventMock, jobMock, trashMock, loggerMock);
}); });
describe('restoreAssets', () => { describe('restoreAssets', () => {
+18 -4
View File
@@ -1,21 +1,35 @@
import { Inject } from '@nestjs/common';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { BaseService } from 'src/services/base.service'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
export class TrashService extends BaseService { export class TrashService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ITrashRepository) private trashRepository: ITrashRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(TrashService.name);
}
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<TrashResponseDto> { async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<TrashResponseDto> {
const { ids } = dto; const { ids } = dto;
if (ids.length === 0) { if (ids.length === 0) {
return { count: 0 }; return { count: 0 };
} }
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids }); await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
await this.trashRepository.restoreAll(ids); await this.trashRepository.restoreAll(ids);
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id }); await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
+22 -3
View File
@@ -1,22 +1,41 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { mapUserAdmin } from 'src/dtos/user.dto'; import { mapUserAdmin } from 'src/dtos/user.dto';
import { UserStatus } from 'src/enum'; import { UserStatus } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { UserAdminService } from 'src/services/user-admin.service'; import { UserAdminService } from 'src/services/user-admin.service';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, describe } from 'vitest'; import { Mocked, describe } from 'vitest';
describe(UserAdminService.name, () => { describe(UserAdminService.name, () => {
let sut: UserAdminService; let sut: UserAdminService;
let albumMock: Mocked<IAlbumRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, jobMock, userMock } = newTestService(UserAdminService)); albumMock = newAlbumRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new UserAdminService(albumMock, cryptoMock, eventMock, jobMock, userMock, loggerMock);
userMock.get.mockImplementation((userId) => userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null), Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),

Some files were not shown because too many files have changed in this diff Show More