From 81f0265095779a5bb4ba8bea53893aea9a2552af Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> Date: Wed, 20 Mar 2024 15:04:03 -0500 Subject: [PATCH] chore: organize config, validation, decorators (#8118) * refactor: validation * refactor: utilities * refactor: config --- .../src/{infra/infra.config.ts => config.ts} | 35 +++ server/src/decorators.ts | 124 ++++++++ server/src/domain/access/access.core.ts | 2 +- server/src/domain/activity/activity.dto.ts | 2 +- server/src/domain/album/album-response.dto.ts | 2 +- server/src/domain/album/album.service.ts | 2 +- .../domain/album/dto/album-add-users.dto.ts | 2 +- .../src/domain/album/dto/album-create.dto.ts | 2 +- .../src/domain/album/dto/album-update.dto.ts | 2 +- server/src/domain/album/dto/album.dto.ts | 2 +- server/src/domain/album/dto/get-albums.dto.ts | 2 +- server/src/domain/api-key/api-key.dto.ts | 2 +- server/src/domain/asset/asset.service.ts | 2 +- server/src/domain/asset/dto/asset-ids.dto.ts | 2 +- .../src/domain/asset/dto/asset-stack.dto.ts | 2 +- .../domain/asset/dto/asset-statistics.dto.ts | 2 +- server/src/domain/asset/dto/asset.dto.ts | 2 +- server/src/domain/asset/dto/map-marker.dto.ts | 2 +- .../src/domain/asset/dto/time-bucket.dto.ts | 2 +- .../response-dto/asset-ids-response.dto.ts | 2 +- server/src/domain/audit/audit.dto.ts | 2 +- server/src/domain/audit/audit.service.ts | 2 +- server/src/domain/auth/auth.service.ts | 2 +- server/src/domain/domain.config.ts | 36 --- server/src/domain/domain.util.ts | 280 ------------------ server/src/domain/download/download.dto.ts | 2 +- .../domain/download/download.service.spec.ts | 2 +- .../src/domain/download/download.service.ts | 2 +- server/src/domain/job/job.dto.ts | 2 +- server/src/domain/library/library.dto.ts | 2 +- server/src/domain/library/library.service.ts | 3 +- server/src/domain/media/media.service.ts | 2 +- .../src/domain/metadata/metadata.service.ts | 2 +- server/src/domain/person/person.dto.ts | 2 +- .../src/domain/person/person.service.spec.ts | 2 +- server/src/domain/person/person.service.ts | 2 +- .../domain/repositories/asset.repository.ts | 2 +- .../domain/repositories/person.repository.ts | 2 +- .../domain/repositories/search.repository.ts | 2 +- .../search/dto/search-suggestion.dto.ts | 2 +- server/src/domain/search/dto/search.dto.ts | 2 +- .../domain/server-info/server-info.service.ts | 2 +- .../src/domain/shared-link/shared-link.dto.ts | 2 +- .../domain/shared-link/shared-link.service.ts | 2 +- .../domain/smart-info/dto/model-config.dto.ts | 2 +- .../domain/smart-info/smart-info.service.ts | 2 +- .../storage-template.service.ts | 2 +- .../dto/system-config-ffmpeg.dto.ts | 2 +- .../dto/system-config-library.dto.ts | 2 +- .../dto/system-config-logging.dto.ts | 2 +- .../dto/system-config-machine-learning.dto.ts | 2 +- .../dto/system-config-map.dto.ts | 2 +- .../system-config-new-version-check.dto.ts | 2 +- .../dto/system-config-oauth.dto.ts | 2 +- .../dto/system-config-password-login.dto.ts | 2 +- .../system-config-reverse-geocoding.dto.ts | 2 +- .../dto/system-config-storage-template.dto.ts | 2 +- .../dto/system-config-trash.dto.ts | 2 +- server/src/domain/tag/tag.dto.ts | 2 +- server/src/domain/trash/trash.service.ts | 2 +- server/src/domain/user/dto/create-user.dto.ts | 2 +- server/src/domain/user/dto/delete-user.dto.ts | 2 +- server/src/domain/user/dto/update-user.dto.ts | 2 +- server/src/domain/user/user.service.spec.ts | 2 +- server/src/domain/user/user.service.ts | 2 +- .../immich/api-v1/asset/asset.controller.ts | 3 +- .../src/immich/api-v1/asset/asset.service.ts | 2 +- .../api-v1/asset/dto/asset-search.dto.ts | 2 +- .../api-v1/asset/dto/create-asset.dto.ts | 2 +- .../asset/dto/get-asset-thumbnail.dto.ts | 2 +- .../immich/api-v1/asset/dto/serve-file.dto.ts | 2 +- .../validation/file-not-empty-validator.ts | 21 -- .../api-v1/validation/parse-me-uuid-pipe.ts | 11 - server/src/immich/app.service.ts | 2 +- server/src/immich/app.utils.ts | 2 +- .../immich/controllers/activity.controller.ts | 2 +- .../immich/controllers/album.controller.ts | 3 +- .../immich/controllers/api-key.controller.ts | 2 +- .../immich/controllers/asset.controller.ts | 2 +- .../src/immich/controllers/auth.controller.ts | 2 +- .../immich/controllers/download.controller.ts | 2 +- .../immich/controllers/dto/uuid-param.dto.ts | 9 - .../src/immich/controllers/face.controller.ts | 2 +- .../immich/controllers/library.controller.ts | 2 +- .../immich/controllers/partner.controller.ts | 2 +- .../immich/controllers/person.controller.ts | 2 +- .../controllers/shared-link.controller.ts | 2 +- .../src/immich/controllers/tag.controller.ts | 2 +- .../src/immich/controllers/user.controller.ts | 2 +- .../immich/interceptors/error.interceptor.ts | 2 +- server/src/immich/main.ts | 2 +- server/src/infra/infra.module.ts | 3 +- server/src/infra/infra.util.ts | 30 -- server/src/infra/infra.utils.ts | 80 +---- server/src/infra/instrumentation.ts | 4 +- .../infra/repositories/access.repository.ts | 3 +- .../infra/repositories/activity.repository.ts | 2 +- .../infra/repositories/album.repository.ts | 5 +- .../infra/repositories/api-key.repository.ts | 2 +- .../infra/repositories/asset.repository.ts | 13 +- .../infra/repositories/database.repository.ts | 2 +- .../src/infra/repositories/job.repository.ts | 2 +- .../infra/repositories/library.repository.ts | 2 +- .../infra/repositories/media.repository.ts | 2 +- .../infra/repositories/metadata.repository.ts | 2 +- .../src/infra/repositories/move.repository.ts | 2 +- .../infra/repositories/person.repository.ts | 6 +- .../infra/repositories/search.repository.ts | 7 +- .../repositories/shared-link.repository.ts | 2 +- .../repositories/system-config.repository.ts | 3 +- .../repositories/user-token.repository.ts | 2 +- .../src/infra/repositories/user.repository.ts | 2 +- server/src/infra/sql-generator/index.ts | 2 +- .../microservices/utils/exif/coordinates.ts | 26 -- .../src/microservices/utils/numbers.spec.ts | 47 --- server/src/microservices/utils/numbers.ts | 19 -- .../coordinates.spec.ts => utils.spec.ts} | 48 ++- server/src/utils.ts | 181 +++++++++++ server/src/validation.ts | 164 ++++++++++ 119 files changed, 666 insertions(+), 684 deletions(-) rename server/src/{infra/infra.config.ts => config.ts} (50%) create mode 100644 server/src/decorators.ts delete mode 100644 server/src/domain/domain.config.ts delete mode 100644 server/src/domain/domain.util.ts delete mode 100644 server/src/immich/api-v1/validation/file-not-empty-validator.ts delete mode 100644 server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts delete mode 100644 server/src/immich/controllers/dto/uuid-param.dto.ts delete mode 100644 server/src/infra/infra.util.ts delete mode 100644 server/src/microservices/utils/exif/coordinates.ts delete mode 100644 server/src/microservices/utils/numbers.spec.ts delete mode 100644 server/src/microservices/utils/numbers.ts rename server/src/{microservices/utils/exif/coordinates.spec.ts => utils.spec.ts} (53%) create mode 100644 server/src/utils.ts create mode 100644 server/src/validation.ts diff --git a/server/src/infra/infra.config.ts b/server/src/config.ts similarity index 50% rename from server/src/infra/infra.config.ts rename to server/src/config.ts index f521b96b75..01f3f1a1c0 100644 --- a/server/src/infra/infra.config.ts +++ b/server/src/config.ts @@ -1,7 +1,42 @@ import { RegisterQueueOptions } from '@nestjs/bullmq'; +import { ConfigModuleOptions } from '@nestjs/config'; import { QueueOptions } from 'bullmq'; import { RedisOptions } from 'ioredis'; +import Joi from 'joi'; import { QueueName } from 'src/domain/job/job.constants'; +import { LogLevel } from 'src/infra/entities/system-config.entity'; + +const WHEN_DB_URL_SET = Joi.when('DB_URL', { + is: Joi.exist(), + then: Joi.string().optional(), + otherwise: Joi.string().required(), +}); + +export const immichAppConfig: ConfigModuleOptions = { + envFilePath: '.env', + isGlobal: true, + validationSchema: Joi.object({ + NODE_ENV: Joi.string().optional().valid('development', 'production', 'staging').default('development'), + LOG_LEVEL: Joi.string() + .optional() + .valid(...Object.values(LogLevel)), + + DB_USERNAME: WHEN_DB_URL_SET, + DB_PASSWORD: WHEN_DB_URL_SET, + DB_DATABASE_NAME: WHEN_DB_URL_SET, + DB_URL: Joi.string().optional(), + DB_VECTOR_EXTENSION: Joi.string().optional().valid('pgvector', 'pgvecto.rs').default('pgvecto.rs'), + + MACHINE_LEARNING_PORT: Joi.number().optional(), + MICROSERVICES_PORT: Joi.number().optional(), + IMMICH_METRICS_PORT: Joi.number().optional(), + + IMMICH_METRICS: Joi.boolean().optional().default(false), + IMMICH_HOST_METRICS: Joi.boolean().optional().default(false), + IMMICH_API_METRICS: Joi.boolean().optional().default(false), + IMMICH_IO_METRICS: Joi.boolean().optional().default(false), + }), +}; function parseRedisConfig(): RedisOptions { const redisUrl = process.env.REDIS_URL; diff --git a/server/src/decorators.ts b/server/src/decorators.ts new file mode 100644 index 0000000000..06dc0bfdcc --- /dev/null +++ b/server/src/decorators.ts @@ -0,0 +1,124 @@ +import { SetMetadata } from '@nestjs/common'; +import _ from 'lodash'; +import { setUnion } from 'src/utils'; + +// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the +// maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching +// by a list of IDs) requires splitting the query into multiple chunks. +// We are rounding down this limit, as queries commonly include other filters and parameters. +export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500; + +/** + * Chunks an array or set into smaller collections of the same type and specified size. + * + * @param collection The collection to chunk. + * @param size The size of each chunk. + */ +function chunks<T>(collection: Array<T>, size: number): Array<Array<T>>; +function chunks<T>(collection: Set<T>, size: number): Array<Set<T>>; +function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>> | Array<Set<T>> { + if (collection instanceof Set) { + const result = []; + let chunk = new Set<T>(); + for (const element of collection) { + chunk.add(element); + if (chunk.size === size) { + result.push(chunk); + chunk = new Set<T>(); + } + } + if (chunk.size > 0) { + result.push(chunk); + } + return result; + } else { + return _.chunk(collection, size); + } +} + +/** + * Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection, + * to overcome the maximum number of parameters allowed by the database driver. + * + * @param options.paramIndex The index of the function parameter to chunk. Defaults to 0. + * @param options.flatten Whether to flatten the results. Defaults to false. + */ +export function Chunked(options: { paramIndex?: number; mergeFn?: (results: any) => any } = {}): MethodDecorator { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + const parameterIndex = options.paramIndex ?? 0; + descriptor.value = async function (...arguments_: any[]) { + const argument = arguments_[parameterIndex]; + + // Early return if argument length is less than or equal to the chunk size. + if ( + (Array.isArray(argument) && argument.length <= DATABASE_PARAMETER_CHUNK_SIZE) || + (argument instanceof Set && argument.size <= DATABASE_PARAMETER_CHUNK_SIZE) + ) { + return await originalMethod.apply(this, arguments_); + } + + return Promise.all( + chunks(argument, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => { + await Reflect.apply(originalMethod, this, [ + ...arguments_.slice(0, parameterIndex), + chunk, + ...arguments_.slice(parameterIndex + 1), + ]); + }), + ).then((results) => (options.mergeFn ? options.mergeFn(results) : results)); + }; + }; +} + +export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator { + return Chunked({ ...options, mergeFn: _.flatten }); +} + +export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator { + return Chunked({ ...options, mergeFn: setUnion }); +} + +// https://stackoverflow.com/a/74898678 +export function DecorateAll( + decorator: <T>( + target: any, + propertyKey: string, + descriptor: TypedPropertyDescriptor<T>, + ) => TypedPropertyDescriptor<T> | void, +) { + return (target: any) => { + const descriptors = Object.getOwnPropertyDescriptors(target.prototype); + for (const [propName, descriptor] of Object.entries(descriptors)) { + const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor'; + if (!isMethod) { + continue; + } + decorator({ ...target, constructor: { ...target.constructor, name: target.name } as any }, propName, descriptor); + Object.defineProperty(target.prototype, propName, descriptor); + } + }; +} + +const UUID = '00000000-0000-4000-a000-000000000000'; + +export const DummyValue = { + UUID, + UUID_SET: new Set([UUID]), + PAGINATION: { take: 10, skip: 0 }, + EMAIL: 'user@immich.app', + STRING: 'abcdefghi', + BUFFER: Buffer.from('abcdefghi'), + DATE: new Date(), + TIME_BUCKET: '2024-01-01T00:00:00.000Z', +}; + +export const GENERATE_SQL_KEY = 'generate-sql-key'; + +export interface GenerateSqlQueries { + name?: string; + params: unknown[]; +} + +/** Decorator to enable versioning/tracking of generated Sql */ +export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index a514128568..eb4ff55d12 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -1,8 +1,8 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthDto } from 'src/domain/auth/auth.dto'; -import { setDifference, setIsEqual, setUnion } from 'src/domain/domain.util'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { SharedLinkEntity } from 'src/infra/entities/shared-link.entity'; +import { setDifference, setIsEqual, setUnion } from 'src/utils'; export enum Permission { ACTIVITY_CREATE = 'activity.create', diff --git a/server/src/domain/activity/activity.dto.ts b/server/src/domain/activity/activity.dto.ts index 627a06c42e..1bfbabd806 100644 --- a/server/src/domain/activity/activity.dto.ts +++ b/server/src/domain/activity/activity.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; -import { Optional, ValidateUUID } from 'src/domain/domain.util'; import { UserDto, mapSimpleUser } from 'src/domain/user/response-dto/user-response.dto'; import { ActivityEntity } from 'src/infra/entities/activity.entity'; +import { Optional, ValidateUUID } from 'src/validation'; export enum ReactionType { COMMENT = 'comment', diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index ab8454a493..663a463f2c 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/server/src/domain/album/album-response.dto.ts @@ -1,9 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { AssetResponseDto, mapAsset } from 'src/domain/asset/response-dto/asset-response.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; -import { Optional } from 'src/domain/domain.util'; import { UserResponseDto, mapUser } from 'src/domain/user/response-dto/user-response.dto'; import { AlbumEntity, AssetOrder } from 'src/infra/entities/album.entity'; +import { Optional } from 'src/validation'; export class AlbumResponseDto { id!: string; diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 7ba91f1d9c..d54680fe42 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -14,7 +14,6 @@ import { AlbumInfoDto } from 'src/domain/album/dto/album.dto'; import { GetAlbumsDto } from 'src/domain/album/dto/get-albums.dto'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/domain/asset/response-dto/asset-ids-response.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; -import { setUnion } from 'src/domain/domain.util'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/domain/repositories/album.repository'; import { IAssetRepository } from 'src/domain/repositories/asset.repository'; @@ -22,6 +21,7 @@ import { IUserRepository } from 'src/domain/repositories/user.repository'; import { AlbumEntity } from 'src/infra/entities/album.entity'; import { AssetEntity } from 'src/infra/entities/asset.entity'; import { UserEntity } from 'src/infra/entities/user.entity'; +import { setUnion } from 'src/utils'; @Injectable() export class AlbumService { diff --git a/server/src/domain/album/dto/album-add-users.dto.ts b/server/src/domain/album/dto/album-add-users.dto.ts index b6a9e8be9f..1a6be48232 100644 --- a/server/src/domain/album/dto/album-add-users.dto.ts +++ b/server/src/domain/album/dto/album-add-users.dto.ts @@ -1,5 +1,5 @@ import { ArrayNotEmpty } from 'class-validator'; -import { ValidateUUID } from 'src/domain/domain.util'; +import { ValidateUUID } from 'src/validation'; export class AddUsersDto { @ValidateUUID({ each: true }) diff --git a/server/src/domain/album/dto/album-create.dto.ts b/server/src/domain/album/dto/album-create.dto.ts index 0765e4d77e..1b4a75332e 100644 --- a/server/src/domain/album/dto/album-create.dto.ts +++ b/server/src/domain/album/dto/album-create.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString } from 'class-validator'; -import { Optional, ValidateUUID } from 'src/domain/domain.util'; +import { Optional, ValidateUUID } from 'src/validation'; export class CreateAlbumDto { @IsString() diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts index c6b21e08d0..1f329cb3c5 100644 --- a/server/src/domain/album/dto/album-update.dto.ts +++ b/server/src/domain/album/dto/album-update.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsString } from 'class-validator'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/domain/domain.util'; import { AssetOrder } from 'src/infra/entities/album.entity'; +import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class UpdateAlbumDto { @Optional() diff --git a/server/src/domain/album/dto/album.dto.ts b/server/src/domain/album/dto/album.dto.ts index f1772b4c2e..fe0eb0d5cf 100644 --- a/server/src/domain/album/dto/album.dto.ts +++ b/server/src/domain/album/dto/album.dto.ts @@ -1,4 +1,4 @@ -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class AlbumInfoDto { @ValidateBoolean({ optional: true }) diff --git a/server/src/domain/album/dto/get-albums.dto.ts b/server/src/domain/album/dto/get-albums.dto.ts index e03c219aa4..15e4f1cf23 100644 --- a/server/src/domain/album/dto/get-albums.dto.ts +++ b/server/src/domain/album/dto/get-albums.dto.ts @@ -1,4 +1,4 @@ -import { ValidateBoolean, ValidateUUID } from 'src/domain/domain.util'; +import { ValidateBoolean, ValidateUUID } from 'src/validation'; export class GetAlbumsDto { @ValidateBoolean({ optional: true }) diff --git a/server/src/domain/api-key/api-key.dto.ts b/server/src/domain/api-key/api-key.dto.ts index 19099ba18c..1f4f855216 100644 --- a/server/src/domain/api-key/api-key.dto.ts +++ b/server/src/domain/api-key/api-key.dto.ts @@ -1,5 +1,5 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { Optional } from 'src/domain/domain.util'; +import { Optional } from 'src/validation'; export class APIKeyCreateDto { @IsString() @IsNotEmpty() diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 3d78c8c7be..2d514a9371 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -21,7 +21,6 @@ import { MapMarkerResponseDto } from 'src/domain/asset/response-dto/map-marker-r import { TimeBucketResponseDto } from 'src/domain/asset/response-dto/time-bucket-response.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; import { mimeTypes } from 'src/domain/domain.constant'; -import { usePagination } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/domain/job/job.constants'; import { IAssetDeletionJob, ISidecarWriteJob } from 'src/domain/job/job.interface'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; @@ -38,6 +37,7 @@ import { SystemConfigCore } from 'src/domain/system-config/system-config.core'; import { AssetEntity } from 'src/infra/entities/asset.entity'; import { LibraryType } from 'src/infra/entities/library.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { usePagination } from 'src/utils'; export enum UploadFieldName { ASSET_DATA = 'assetData', diff --git a/server/src/domain/asset/dto/asset-ids.dto.ts b/server/src/domain/asset/dto/asset-ids.dto.ts index ae236f13db..ea875e85e5 100644 --- a/server/src/domain/asset/dto/asset-ids.dto.ts +++ b/server/src/domain/asset/dto/asset-ids.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum } from 'class-validator'; -import { ValidateUUID } from 'src/domain/domain.util'; +import { ValidateUUID } from 'src/validation'; export class AssetIdsDto { @ValidateUUID({ each: true }) diff --git a/server/src/domain/asset/dto/asset-stack.dto.ts b/server/src/domain/asset/dto/asset-stack.dto.ts index e05bf6a0b2..3ff04ee5ed 100644 --- a/server/src/domain/asset/dto/asset-stack.dto.ts +++ b/server/src/domain/asset/dto/asset-stack.dto.ts @@ -1,4 +1,4 @@ -import { ValidateUUID } from 'src/domain/domain.util'; +import { ValidateUUID } from 'src/validation'; export class UpdateStackParentDto { @ValidateUUID() diff --git a/server/src/domain/asset/dto/asset-statistics.dto.ts b/server/src/domain/asset/dto/asset-statistics.dto.ts index 9d935789ac..7694955c27 100644 --- a/server/src/domain/asset/dto/asset-statistics.dto.ts +++ b/server/src/domain/asset/dto/asset-statistics.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ValidateBoolean } from 'src/domain/domain.util'; import { AssetStats } from 'src/domain/repositories/asset.repository'; import { AssetType } from 'src/infra/entities/asset.entity'; +import { ValidateBoolean } from 'src/validation'; export class AssetStatsDto { @ValidateBoolean({ optional: true }) diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index 31eaa916e8..a93a59ae3b 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -10,7 +10,7 @@ import { ValidateIf, } from 'class-validator'; import { BulkIdsDto } from 'src/domain/asset/response-dto/asset-ids-response.dto'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/domain/domain.util'; +import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @IsNotEmpty() diff --git a/server/src/domain/asset/dto/map-marker.dto.ts b/server/src/domain/asset/dto/map-marker.dto.ts index f06dc201e2..158750e516 100644 --- a/server/src/domain/asset/dto/map-marker.dto.ts +++ b/server/src/domain/asset/dto/map-marker.dto.ts @@ -1,4 +1,4 @@ -import { ValidateBoolean, ValidateDate } from 'src/domain/domain.util'; +import { ValidateBoolean, ValidateDate } from 'src/validation'; export class MapMarkerDto { @ValidateBoolean({ optional: true }) diff --git a/server/src/domain/asset/dto/time-bucket.dto.ts b/server/src/domain/asset/dto/time-bucket.dto.ts index 052e3732e2..c9e104256a 100644 --- a/server/src/domain/asset/dto/time-bucket.dto.ts +++ b/server/src/domain/asset/dto/time-bucket.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/domain/domain.util'; import { TimeBucketSize } from 'src/domain/repositories/asset.repository'; import { AssetOrder } from 'src/infra/entities/album.entity'; +import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { @IsNotEmpty() diff --git a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts index 3a7e8129a4..fdc9942e37 100644 --- a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts @@ -1,4 +1,4 @@ -import { ValidateUUID } from 'src/domain/domain.util'; +import { ValidateUUID } from 'src/validation'; /** @deprecated Use `BulkIdResponseDto` instead */ export enum AssetIdErrorReason { diff --git a/server/src/domain/audit/audit.dto.ts b/server/src/domain/audit/audit.dto.ts index 131ce147b2..1c34e94916 100644 --- a/server/src/domain/audit/audit.dto.ts +++ b/server/src/domain/audit/audit.dto.ts @@ -1,9 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { Optional, ValidateDate, ValidateUUID } from 'src/domain/domain.util'; import { EntityType } from 'src/infra/entities/audit.entity'; import { AssetPathType, PathType, PersonPathType, UserPathType } from 'src/infra/entities/move.entity'; +import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType }); diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index 0bcb2ed15d..c72d1d8cf1 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -12,7 +12,6 @@ import { } from 'src/domain/audit/audit.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; import { AUDIT_LOG_MAX_DURATION } from 'src/domain/domain.constant'; -import { usePagination } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/domain/job/job.constants'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { IAssetRepository } from 'src/domain/repositories/asset.repository'; @@ -26,6 +25,7 @@ import { StorageCore, StorageFolder } from 'src/domain/storage/storage.core'; import { DatabaseAction } from 'src/infra/entities/audit.entity'; import { AssetPathType, PersonPathType, UserPathType } from 'src/infra/entities/move.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { usePagination } from 'src/utils'; @Injectable() export class AuditService { diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts index bb01e1b3b9..e66a6d0d70 100644 --- a/server/src/domain/auth/auth.service.ts +++ b/server/src/domain/auth/auth.service.ts @@ -34,7 +34,6 @@ import { mapLoginResponse, mapUserToken, } from 'src/domain/auth/auth.dto'; -import { HumanReadableSize } from 'src/domain/domain.util'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { IKeyRepository } from 'src/domain/repositories/api-key.repository'; import { ICryptoRepository } from 'src/domain/repositories/crypto.repository'; @@ -49,6 +48,7 @@ import { UserCore } from 'src/domain/user/user.core'; import { SystemConfig } from 'src/infra/entities/system-config.entity'; import { UserEntity } from 'src/infra/entities/user.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { HumanReadableSize } from 'src/utils'; export interface LoginDetails { isSecure: boolean; diff --git a/server/src/domain/domain.config.ts b/server/src/domain/domain.config.ts deleted file mode 100644 index 40580b8e4c..0000000000 --- a/server/src/domain/domain.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -// TODO: remove nestjs references from domain -import { ConfigModuleOptions } from '@nestjs/config'; -import Joi from 'joi'; -import { LogLevel } from 'src/infra/entities/system-config.entity'; - -const WHEN_DB_URL_SET = Joi.when('DB_URL', { - is: Joi.exist(), - then: Joi.string().optional(), - otherwise: Joi.string().required(), -}); - -export const immichAppConfig: ConfigModuleOptions = { - envFilePath: '.env', - isGlobal: true, - validationSchema: Joi.object({ - NODE_ENV: Joi.string().optional().valid('development', 'production', 'staging').default('development'), - LOG_LEVEL: Joi.string() - .optional() - .valid(...Object.values(LogLevel)), - - DB_USERNAME: WHEN_DB_URL_SET, - DB_PASSWORD: WHEN_DB_URL_SET, - DB_DATABASE_NAME: WHEN_DB_URL_SET, - DB_URL: Joi.string().optional(), - DB_VECTOR_EXTENSION: Joi.string().optional().valid('pgvector', 'pgvecto.rs').default('pgvecto.rs'), - - MACHINE_LEARNING_PORT: Joi.number().optional(), - MICROSERVICES_PORT: Joi.number().optional(), - IMMICH_METRICS_PORT: Joi.number().optional(), - - IMMICH_METRICS: Joi.boolean().optional().default(false), - IMMICH_HOST_METRICS: Joi.boolean().optional().default(false), - IMMICH_API_METRICS: Joi.boolean().optional().default(false), - IMMICH_IO_METRICS: Joi.boolean().optional().default(false), - }), -}; diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts deleted file mode 100644 index 820c17f3f0..0000000000 --- a/server/src/domain/domain.util.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { BadRequestException, applyDecorators } from '@nestjs/common'; -import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsDate, - IsNotEmpty, - IsOptional, - IsString, - IsUUID, - ValidateIf, - ValidationOptions, - isDateString, -} from 'class-validator'; -import { CronJob } from 'cron'; -import _ from 'lodash'; -import { basename, extname } from 'node:path'; -import sanitize from 'sanitize-filename'; -import { ImmichLogger } from 'src/infra/logger'; - -export enum CacheControl { - PRIVATE_WITH_CACHE = 'private_with_cache', - PRIVATE_WITHOUT_CACHE = 'private_without_cache', - NONE = 'none', -} - -export class ImmichFileResponse { - public readonly path!: string; - public readonly contentType!: string; - public readonly cacheControl!: CacheControl; - - constructor(response: ImmichFileResponse) { - Object.assign(this, response); - } -} - -export interface OpenGraphTags { - title: string; - description: string; - imageUrl?: string; -} - -export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; - -type UUIDOptions = { optional?: boolean; each?: boolean }; -export const ValidateUUID = (options?: UUIDOptions) => { - const { optional, each } = { optional: false, each: false, ...options }; - return applyDecorators( - IsUUID('4', { each }), - ApiProperty({ format: 'uuid' }), - optional ? Optional() : IsNotEmpty(), - each ? IsArray() : IsString(), - ); -}; - -type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; -export const ValidateDate = (options?: DateOptions) => { - const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options }; - - const decorators = [ - ApiProperty({ format }), - IsDate(), - optional ? Optional({ nullable: true }) : IsNotEmpty(), - Transform(({ key, value }) => { - if (value === null || value === undefined) { - return value; - } - - if (!isDateString(value)) { - throw new BadRequestException(`${key} must be a date string`); - } - - return new Date(value as string); - }), - ]; - - if (optional) { - decorators.push(Optional({ nullable })); - } - - return applyDecorators(...decorators); -}; - -type BooleanOptions = { optional?: boolean }; -export const ValidateBoolean = (options?: BooleanOptions) => { - const { optional } = { optional: false, ...options }; - const decorators = [ - // ApiProperty(), - IsBoolean(), - Transform(({ value }) => { - if (value == 'true') { - return true; - } else if (value == 'false') { - return false; - } - return value; - }), - ]; - - if (optional) { - decorators.push(Optional()); - } - - return applyDecorators(...decorators); -}; - -export function validateCronExpression(expression: string) { - try { - new CronJob(expression, () => {}); - } catch { - return false; - } - - return true; -} - -type IValue = { value: string }; - -export const toEmail = ({ value }: IValue) => value?.toLowerCase(); - -export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', '')); - -export function getFileNameWithoutExtension(path: string): string { - return basename(path, extname(path)); -} - -export function getLivePhotoMotionFilename(stillName: string, motionName: string) { - return getFileNameWithoutExtension(stillName) + extname(motionName); -} - -const KiB = Math.pow(1024, 1); -const MiB = Math.pow(1024, 2); -const GiB = Math.pow(1024, 3); -const TiB = Math.pow(1024, 4); -const PiB = Math.pow(1024, 5); - -export const HumanReadableSize = { KiB, MiB, GiB, TiB, PiB }; - -export function asHumanReadable(bytes: number, precision = 1): string { - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB']; - - let magnitude = 0; - let remainder = bytes; - while (remainder >= 1024) { - if (magnitude + 1 < units.length) { - magnitude++; - remainder /= 1024; - } else { - break; - } - } - - return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`; -} - -export interface PaginationOptions { - take: number; - skip?: number; -} - -export enum PaginationMode { - LIMIT_OFFSET = 'limit-offset', - SKIP_TAKE = 'skip-take', -} - -export interface PaginatedBuilderOptions { - take: number; - skip?: number; - mode?: PaginationMode; -} - -export interface PaginationResult<T> { - items: T[]; - hasNextPage: boolean; -} - -export type Paginated<T> = Promise<PaginationResult<T>>; - -export async function* usePagination<T>( - pageSize: number, - getNextPage: (pagination: PaginationOptions) => PaginationResult<T> | Paginated<T>, -) { - let hasNextPage = true; - - for (let skip = 0; hasNextPage; skip += pageSize) { - const result = await getNextPage({ take: pageSize, skip }); - hasNextPage = result.hasNextPage; - yield result.items; - } -} - -export interface OptionalOptions extends ValidationOptions { - nullable?: boolean; -} - -/** - * Checks if value is missing and if so, ignores all validators. - * - * @param validationOptions {@link OptionalOptions} - * - * @see IsOptional exported from `class-validator. - */ -// https://stackoverflow.com/a/71353929 -export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) { - if (nullable === true) { - return IsOptional(validationOptions); - } - - return ValidateIf((object: any, v: any) => v !== undefined, validationOptions); -} - -/** - * Chunks an array or set into smaller collections of the same type and specified size. - * - * @param collection The collection to chunk. - * @param size The size of each chunk. - */ -export function chunks<T>(collection: Array<T>, size: number): Array<Array<T>>; -export function chunks<T>(collection: Set<T>, size: number): Array<Set<T>>; -export function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>> | Array<Set<T>> { - if (collection instanceof Set) { - const result = []; - let chunk = new Set<T>(); - for (const element of collection) { - chunk.add(element); - if (chunk.size === size) { - result.push(chunk); - chunk = new Set<T>(); - } - } - if (chunk.size > 0) { - result.push(chunk); - } - return result; - } else { - return _.chunk(collection, size); - } -} - -// NOTE: The following Set utils have been added here, to easily determine where they are used. -// They should be replaced with native Set operations, when they are added to the language. -// Proposal reference: https://github.com/tc39/proposal-set-methods - -export const setUnion = <T>(...sets: Set<T>[]): Set<T> => { - const union = new Set(sets[0]); - for (const set of sets.slice(1)) { - for (const element of set) { - union.add(element); - } - } - return union; -}; - -export const setDifference = <T>(setA: Set<T>, ...sets: Set<T>[]): Set<T> => { - const difference = new Set(setA); - for (const set of sets) { - for (const element of set) { - difference.delete(element); - } - } - return difference; -}; - -export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => { - for (const element of subset) { - if (!set.has(element)) { - return false; - } - } - return true; -}; - -export const setIsEqual = <T>(setA: Set<T>, setB: Set<T>): boolean => { - return setA.size === setB.size && setIsSuperset(setA, setB); -}; - -export const handlePromiseError = <T>(promise: Promise<T>, logger: ImmichLogger): void => { - promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack)); -}; diff --git a/server/src/domain/download/download.dto.ts b/server/src/domain/download/download.dto.ts index e350d66acf..e6588a9944 100644 --- a/server/src/domain/download/download.dto.ts +++ b/server/src/domain/download/download.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsInt, IsPositive } from 'class-validator'; -import { Optional, ValidateUUID } from 'src/domain/domain.util'; +import { Optional, ValidateUUID } from 'src/validation'; export class DownloadInfoDto { @ValidateUUID({ each: true, optional: true }) diff --git a/server/src/domain/download/download.service.spec.ts b/server/src/domain/download/download.service.spec.ts index d22ce69e8d..7c3ecbb026 100644 --- a/server/src/domain/download/download.service.spec.ts +++ b/server/src/domain/download/download.service.spec.ts @@ -1,10 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { when } from 'jest-when'; -import { CacheControl, ImmichFileResponse } from 'src/domain/domain.util'; import { DownloadResponseDto } from 'src/domain/download/download.dto'; import { DownloadService } from 'src/domain/download/download.service'; import { IAssetRepository } from 'src/domain/repositories/asset.repository'; import { IStorageRepository } from 'src/domain/repositories/storage.repository'; +import { CacheControl, ImmichFileResponse } from 'src/utils'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts index b069ac9b30..13f513c9be 100644 --- a/server/src/domain/download/download.service.ts +++ b/server/src/domain/download/download.service.ts @@ -4,12 +4,12 @@ import { AccessCore, Permission } from 'src/domain/access/access.core'; import { AssetIdsDto } from 'src/domain/asset/dto/asset-ids.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; import { mimeTypes } from 'src/domain/domain.constant'; -import { CacheControl, HumanReadableSize, ImmichFileResponse, usePagination } from 'src/domain/domain.util'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/domain/download/download.dto'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { IAssetRepository } from 'src/domain/repositories/asset.repository'; import { IStorageRepository, ImmichReadStream } from 'src/domain/repositories/storage.repository'; import { AssetEntity } from 'src/infra/entities/asset.entity'; +import { CacheControl, HumanReadableSize, ImmichFileResponse, usePagination } from 'src/utils'; @Injectable() export class DownloadService { diff --git a/server/src/domain/job/job.dto.ts b/server/src/domain/job/job.dto.ts index f551ab9613..fd463a9b05 100644 --- a/server/src/domain/job/job.dto.ts +++ b/server/src/domain/job/job.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; -import { ValidateBoolean } from 'src/domain/domain.util'; import { JobCommand, QueueName } from 'src/domain/job/job.constants'; +import { ValidateBoolean } from 'src/validation'; export class JobIdParamDto { @IsNotEmpty() diff --git a/server/src/domain/library/library.dto.ts b/server/src/domain/library/library.dto.ts index 26faf43407..f65779fb85 100644 --- a/server/src/domain/library/library.dto.ts +++ b/server/src/domain/library/library.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayUnique, IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/domain/domain.util'; import { LibraryEntity, LibraryType } from 'src/infra/entities/library.entity'; +import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { @IsEnum(LibraryType) diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 7ad781f283..46b4d11f5b 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -7,7 +7,6 @@ import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { mimeTypes } from 'src/domain/domain.constant'; -import { handlePromiseError, usePagination, validateCronExpression } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/domain/job/job.constants'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob } from 'src/domain/job/job.interface'; import { @@ -35,6 +34,8 @@ import { SystemConfigCore } from 'src/domain/system-config/system-config.core'; import { AssetType } from 'src/infra/entities/asset.entity'; import { LibraryEntity, LibraryType } from 'src/infra/entities/library.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { handlePromiseError, usePagination } from 'src/utils'; +import { validateCronExpression } from 'src/validation'; const LIBRARY_SCAN_BATCH_SIZE = 5000; diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index bb0c2503d9..f06f9ed28d 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; -import { usePagination } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from 'src/domain/job/job.constants'; import { IBaseJob, IEntityJob } from 'src/domain/job/job.interface'; import { @@ -39,6 +38,7 @@ import { VideoCodec, } from 'src/infra/entities/system-config.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { usePagination } from 'src/utils'; @Injectable() export class MediaService { diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 4f45b98533..360f916acb 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -6,7 +6,6 @@ import { Duration } from 'luxon'; import { constants } from 'node:fs/promises'; import path from 'node:path'; import { Subscription } from 'rxjs'; -import { handlePromiseError, usePagination } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from 'src/domain/job/job.constants'; import { IBaseJob, IEntityJob, ISidecarWriteJob } from 'src/domain/job/job.interface'; import { IAlbumRepository } from 'src/domain/repositories/album.repository'; @@ -26,6 +25,7 @@ import { FeatureFlag, SystemConfigCore } from 'src/domain/system-config/system-c import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity'; import { ExifEntity } from 'src/infra/entities/exif.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { handlePromiseError, usePagination } from 'src/utils'; /** look for a date from these tags (in order) */ const EXIF_DATE_TAGS: Array<keyof Tags> = [ diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index 9ac66332f8..f3699499aa 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsNotEmpty, IsString, MaxDate, ValidateNested } from 'class-validator'; import { AuthDto } from 'src/domain/auth/auth.dto'; -import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/domain/domain.util'; import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity'; import { PersonEntity } from 'src/infra/entities/person.entity'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class PersonCreateDto { /** diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index b0aa3c33ac..a4b1282d32 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -1,6 +1,5 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/domain/asset/response-dto/asset-ids-response.dto'; -import { CacheControl, ImmichFileResponse } from 'src/domain/domain.util'; import { JobName } from 'src/domain/job/job.constants'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/domain/person/person.dto'; import { PersonService } from 'src/domain/person/person.service'; @@ -16,6 +15,7 @@ import { IStorageRepository } from 'src/domain/repositories/storage.repository'; import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository'; import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity'; import { Colorspace, SystemConfigKey } from 'src/infra/entities/system-config.entity'; +import { CacheControl, ImmichFileResponse } from 'src/utils'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 1928191a3e..46538e04f3 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -4,7 +4,6 @@ import { BulkIdErrorReason, BulkIdResponseDto } from 'src/domain/asset/response- import { AssetResponseDto, mapAsset } from 'src/domain/asset/response-dto/asset-response.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; import { mimeTypes } from 'src/domain/domain.constant'; -import { CacheControl, ImmichFileResponse, usePagination } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from 'src/domain/job/job.constants'; import { IBaseJob, IDeferrableJob, IEntityJob } from 'src/domain/job/job.interface'; import { FACE_THUMBNAIL_SIZE } from 'src/domain/media/media.constant'; @@ -39,6 +38,7 @@ import { SystemConfigCore } from 'src/domain/system-config/system-config.core'; import { PersonPathType } from 'src/infra/entities/move.entity'; import { PersonEntity } from 'src/infra/entities/person.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { CacheControl, ImmichFileResponse, usePagination } from 'src/utils'; import { IsNull } from 'typeorm'; @Injectable() diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index e8bbdeb389..336db8d70a 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,10 +1,10 @@ -import { Paginated, PaginationOptions } from 'src/domain/domain.util'; import { ReverseGeocodeResult } from 'src/domain/repositories/metadata.repository'; import { AssetSearchOptions, SearchExploreItem } from 'src/domain/repositories/search.repository'; import { AssetOrder } from 'src/infra/entities/album.entity'; import { AssetJobStatusEntity } from 'src/infra/entities/asset-job-status.entity'; import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity'; import { ExifEntity } from 'src/infra/entities/exif.entity'; +import { Paginated, PaginationOptions } from 'src/utils'; import { FindOptionsRelations, FindOptionsSelect } from 'typeorm'; export type AssetStats = Record<AssetType, number>; diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index 93787f7b90..f00fce44ef 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -1,7 +1,7 @@ -import { Paginated, PaginationOptions } from 'src/domain/domain.util'; import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity'; import { AssetEntity } from 'src/infra/entities/asset.entity'; import { PersonEntity } from 'src/infra/entities/person.entity'; +import { Paginated, PaginationOptions } from 'src/utils'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; export const IPersonRepository = 'IPersonRepository'; diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 3dc9c1ea50..a54147f1f9 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -1,8 +1,8 @@ -import { Paginated } from 'src/domain/domain.util'; import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity'; import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/infra/entities/geodata-places.entity'; import { SmartInfoEntity } from 'src/infra/entities/smart-info.entity'; +import { Paginated } from 'src/utils'; export const ISearchRepository = 'ISearchRepository'; diff --git a/server/src/domain/search/dto/search-suggestion.dto.ts b/server/src/domain/search/dto/search-suggestion.dto.ts index f2f70062a0..f702293d0c 100644 --- a/server/src/domain/search/dto/search-suggestion.dto.ts +++ b/server/src/domain/search/dto/search-suggestion.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { Optional } from 'src/domain/domain.util'; +import { Optional } from 'src/validation'; export enum SearchSuggestionType { COUNTRY = 'country', diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 0dcaa22fb5..52090b9456 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -1,10 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; -import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/domain/domain.util'; import { AssetOrder } from 'src/infra/entities/album.entity'; import { AssetType } from 'src/infra/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/infra/entities/geodata-places.entity'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class BaseSearchDto { @ValidateUUID({ optional: true }) diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index 77c675b87e..3ceb3039d9 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { Version, isDev, mimeTypes, serverVersion } from 'src/domain/domain.constant'; -import { asHumanReadable } from 'src/domain/domain.util'; import { ClientEvent, ICommunicationRepository } from 'src/domain/repositories/communication.repository'; import { IServerInfoRepository } from 'src/domain/repositories/server-info.repository'; import { IStorageRepository } from 'src/domain/repositories/storage.repository'; @@ -21,6 +20,7 @@ import { StorageCore, StorageFolder } from 'src/domain/storage/storage.core'; import { SystemConfigCore } from 'src/domain/system-config/system-config.core'; import { SystemMetadataKey } from 'src/infra/entities/system-metadata.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { asHumanReadable } from 'src/utils'; @Injectable() export class ServerInfoService { diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts index ea529979c9..7ef9a77334 100644 --- a/server/src/domain/shared-link/shared-link.dto.ts +++ b/server/src/domain/shared-link/shared-link.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsString } from 'class-validator'; -import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/domain/domain.util'; import { SharedLinkType } from 'src/infra/entities/shared-link.entity'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class SharedLinkCreateDto { @IsEnum(SharedLinkType) diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts index a5a28b374f..b550854922 100644 --- a/server/src/domain/shared-link/shared-link.service.ts +++ b/server/src/domain/shared-link/shared-link.service.ts @@ -3,7 +3,6 @@ import { AccessCore, Permission } from 'src/domain/access/access.core'; import { AssetIdsDto } from 'src/domain/asset/dto/asset-ids.dto'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/domain/asset/response-dto/asset-ids-response.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; -import { OpenGraphTags } from 'src/domain/domain.util'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { ICryptoRepository } from 'src/domain/repositories/crypto.repository'; import { ISharedLinkRepository } from 'src/domain/repositories/shared-link.repository'; @@ -15,6 +14,7 @@ import { import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from 'src/domain/shared-link/shared-link.dto'; import { AssetEntity } from 'src/infra/entities/asset.entity'; import { SharedLinkEntity, SharedLinkType } from 'src/infra/entities/shared-link.entity'; +import { OpenGraphTags } from 'src/utils'; @Injectable() export class SharedLinkService { diff --git a/server/src/domain/smart-info/dto/model-config.dto.ts b/server/src/domain/smart-info/dto/model-config.dto.ts index 0b12e1cc61..e0977c4dae 100644 --- a/server/src/domain/smart-info/dto/model-config.dto.ts +++ b/server/src/domain/smart-info/dto/model-config.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator'; -import { Optional, ValidateBoolean } from 'src/domain/domain.util'; import { CLIPMode, ModelType } from 'src/domain/repositories/machine-learning.repository'; +import { Optional, ValidateBoolean } from 'src/validation'; export class ModelConfig { @ValidateBoolean() diff --git a/server/src/domain/smart-info/smart-info.service.ts b/server/src/domain/smart-info/smart-info.service.ts index 8f20f56d50..fdf9862f8c 100644 --- a/server/src/domain/smart-info/smart-info.service.ts +++ b/server/src/domain/smart-info/smart-info.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { usePagination } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from 'src/domain/job/job.constants'; import { IBaseJob, IEntityJob } from 'src/domain/job/job.interface'; import { IAssetRepository, WithoutProperty } from 'src/domain/repositories/asset.repository'; @@ -10,6 +9,7 @@ import { ISearchRepository } from 'src/domain/repositories/search.repository'; import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository'; import { SystemConfigCore } from 'src/domain/system-config/system-config.core'; import { ImmichLogger } from 'src/infra/logger'; +import { usePagination } from 'src/utils'; @Injectable() export class SmartInfoService { diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index 8028eb359b..f3e24c056d 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -4,7 +4,6 @@ import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; -import { getLivePhotoMotionFilename, usePagination } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/domain/job/job.constants'; import { IEntityJob } from 'src/domain/job/job.interface'; import { IAlbumRepository } from 'src/domain/repositories/album.repository'; @@ -33,6 +32,7 @@ import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity'; import { AssetPathType } from 'src/infra/entities/move.entity'; import { SystemConfig } from 'src/infra/entities/system-config.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { getLivePhotoMotionFilename, usePagination } from 'src/utils'; export interface MoveAssetMetadata { storageLabel: string | null; diff --git a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts index 99077176bb..16f81b37ca 100644 --- a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts +++ b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsString, Max, Min } from 'class-validator'; -import { ValidateBoolean } from 'src/domain/domain.util'; import { AudioCodec, CQMode, @@ -10,6 +9,7 @@ import { TranscodePolicy, VideoCodec, } from 'src/infra/entities/system-config.entity'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigFFmpegDto { @IsInt() diff --git a/server/src/domain/system-config/dto/system-config-library.dto.ts b/server/src/domain/system-config/dto/system-config-library.dto.ts index f322423dff..8c7501ae40 100644 --- a/server/src/domain/system-config/dto/system-config-library.dto.ts +++ b/server/src/domain/system-config/dto/system-config-library.dto.ts @@ -9,7 +9,7 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; -import { ValidateBoolean, validateCronExpression } from 'src/domain/domain.util'; +import { ValidateBoolean, validateCronExpression } from 'src/validation'; const isEnabled = (config: SystemConfigLibraryScanDto) => config.enabled; diff --git a/server/src/domain/system-config/dto/system-config-logging.dto.ts b/server/src/domain/system-config/dto/system-config-logging.dto.ts index 0f1664ee39..f41b568a24 100644 --- a/server/src/domain/system-config/dto/system-config-logging.dto.ts +++ b/server/src/domain/system-config/dto/system-config-logging.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum } from 'class-validator'; -import { ValidateBoolean } from 'src/domain/domain.util'; import { LogLevel } from 'src/infra/entities/system-config.entity'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigLoggingDto { @ValidateBoolean() diff --git a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts index 1b33d5920f..0585859920 100644 --- a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts +++ b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { IsObject, IsUrl, ValidateIf, ValidateNested } from 'class-validator'; -import { ValidateBoolean } from 'src/domain/domain.util'; import { CLIPConfig, RecognitionConfig } from 'src/domain/smart-info/dto/model-config.dto'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigMachineLearningDto { @ValidateBoolean() diff --git a/server/src/domain/system-config/dto/system-config-map.dto.ts b/server/src/domain/system-config/dto/system-config-map.dto.ts index fb8aac5935..9ec0abfa4e 100644 --- a/server/src/domain/system-config/dto/system-config-map.dto.ts +++ b/server/src/domain/system-config/dto/system-config-map.dto.ts @@ -1,5 +1,5 @@ import { IsString } from 'class-validator'; -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigMapDto { @ValidateBoolean() diff --git a/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts b/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts index 27afe1e680..7d5c5134f2 100644 --- a/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts +++ b/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts @@ -1,4 +1,4 @@ -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigNewVersionCheckDto { @ValidateBoolean() diff --git a/server/src/domain/system-config/dto/system-config-oauth.dto.ts b/server/src/domain/system-config/dto/system-config-oauth.dto.ts index 13465b7722..9c7fc5f408 100644 --- a/server/src/domain/system-config/dto/system-config-oauth.dto.ts +++ b/server/src/domain/system-config/dto/system-config-oauth.dto.ts @@ -1,5 +1,5 @@ import { IsNotEmpty, IsNumber, IsString, IsUrl, Min, ValidateIf } from 'class-validator'; -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; const isEnabled = (config: SystemConfigOAuthDto) => config.enabled; const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled; diff --git a/server/src/domain/system-config/dto/system-config-password-login.dto.ts b/server/src/domain/system-config/dto/system-config-password-login.dto.ts index 3d98ae0459..8d49a7002d 100644 --- a/server/src/domain/system-config/dto/system-config-password-login.dto.ts +++ b/server/src/domain/system-config/dto/system-config-password-login.dto.ts @@ -1,4 +1,4 @@ -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigPasswordLoginDto { @ValidateBoolean() diff --git a/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts b/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts index ad4bcaab64..8ff2866012 100644 --- a/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts +++ b/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts @@ -1,4 +1,4 @@ -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigReverseGeocodingDto { @ValidateBoolean() diff --git a/server/src/domain/system-config/dto/system-config-storage-template.dto.ts b/server/src/domain/system-config/dto/system-config-storage-template.dto.ts index c35dcd4769..77204b46e8 100644 --- a/server/src/domain/system-config/dto/system-config-storage-template.dto.ts +++ b/server/src/domain/system-config/dto/system-config-storage-template.dto.ts @@ -1,5 +1,5 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigStorageTemplateDto { @ValidateBoolean() diff --git a/server/src/domain/system-config/dto/system-config-trash.dto.ts b/server/src/domain/system-config/dto/system-config-trash.dto.ts index 8765b4ff19..a9e5483ebf 100644 --- a/server/src/domain/system-config/dto/system-config-trash.dto.ts +++ b/server/src/domain/system-config/dto/system-config-trash.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, Min } from 'class-validator'; -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class SystemConfigTrashDto { @ValidateBoolean() diff --git a/server/src/domain/tag/tag.dto.ts b/server/src/domain/tag/tag.dto.ts index e0c27294a0..322f40acf0 100644 --- a/server/src/domain/tag/tag.dto.ts +++ b/server/src/domain/tag/tag.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { Optional } from 'src/domain/domain.util'; import { TagType } from 'src/infra/entities/tag.entity'; +import { Optional } from 'src/validation'; export class CreateTagDto { @IsString() diff --git a/server/src/domain/trash/trash.service.ts b/server/src/domain/trash/trash.service.ts index 230ce77f33..aa95a48897 100644 --- a/server/src/domain/trash/trash.service.ts +++ b/server/src/domain/trash/trash.service.ts @@ -3,12 +3,12 @@ import { DateTime } from 'luxon'; import { AccessCore, Permission } from 'src/domain/access/access.core'; import { BulkIdsDto } from 'src/domain/asset/response-dto/asset-ids-response.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; -import { usePagination } from 'src/domain/domain.util'; import { JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/domain/job/job.constants'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { IAssetRepository } from 'src/domain/repositories/asset.repository'; import { ClientEvent, ICommunicationRepository } from 'src/domain/repositories/communication.repository'; import { IJobRepository } from 'src/domain/repositories/job.repository'; +import { usePagination } from 'src/utils'; export class TrashService { private access: AccessCore; diff --git a/server/src/domain/user/dto/create-user.dto.ts b/server/src/domain/user/dto/create-user.dto.ts index aa295cb2b5..7861c58c25 100644 --- a/server/src/domain/user/dto/create-user.dto.ts +++ b/server/src/domain/user/dto/create-user.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; -import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/domain/domain.util'; +import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; export class CreateUserDto { @IsEmail({ require_tld: false }) diff --git a/server/src/domain/user/dto/delete-user.dto.ts b/server/src/domain/user/dto/delete-user.dto.ts index 09b1c5bf35..aa41e18aad 100644 --- a/server/src/domain/user/dto/delete-user.dto.ts +++ b/server/src/domain/user/dto/delete-user.dto.ts @@ -1,4 +1,4 @@ -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class DeleteUserDto { @ValidateBoolean({ optional: true }) diff --git a/server/src/domain/user/dto/update-user.dto.ts b/server/src/domain/user/dto/update-user.dto.ts index 605017e548..c7c47c4cff 100644 --- a/server/src/domain/user/dto/update-user.dto.ts +++ b/server/src/domain/user/dto/update-user.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator'; -import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/domain/domain.util'; import { UserAvatarColor } from 'src/infra/entities/user.entity'; +import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; export class UpdateUserDto { @Optional() diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index 8fed91c71c..d590d7fda2 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -5,7 +5,6 @@ import { NotFoundException, } from '@nestjs/common'; import { when } from 'jest-when'; -import { CacheControl, ImmichFileResponse } from 'src/domain/domain.util'; import { JobName } from 'src/domain/job/job.constants'; import { IAlbumRepository } from 'src/domain/repositories/album.repository'; import { ICryptoRepository } from 'src/domain/repositories/crypto.repository'; @@ -18,6 +17,7 @@ import { UpdateUserDto } from 'src/domain/user/dto/update-user.dto'; import { mapUser } from 'src/domain/user/response-dto/user-response.dto'; import { UserService } from 'src/domain/user/user.service'; import { UserEntity, UserStatus } from 'src/infra/entities/user.entity'; +import { CacheControl, ImmichFileResponse } from 'src/utils'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 18f485b003..6eca5ff559 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundEx import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { AuthDto } from 'src/domain/auth/auth.dto'; -import { CacheControl, ImmichFileResponse } from 'src/domain/domain.util'; import { JobName } from 'src/domain/job/job.constants'; import { IEntityJob } from 'src/domain/job/job.interface'; import { IAlbumRepository } from 'src/domain/repositories/album.repository'; @@ -25,6 +24,7 @@ import { UserResponseDto, mapUser } from 'src/domain/user/response-dto/user-resp import { UserCore } from 'src/domain/user/user.core'; import { UserEntity, UserStatus } from 'src/infra/entities/user.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { CacheControl, ImmichFileResponse } from 'src/utils'; @Injectable() export class UserService { diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index 3faf33641d..0496c75be6 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -29,16 +29,15 @@ import { AssetFileUploadResponseDto } from 'src/immich/api-v1/asset/response-dto import { CheckExistingAssetsResponseDto } from 'src/immich/api-v1/asset/response-dto/check-existing-assets-response.dto'; import { CuratedLocationsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-locations-response.dto'; import { CuratedObjectsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-objects-response.dto'; -import FileNotEmptyValidator from 'src/immich/api-v1/validation/file-not-empty-validator'; import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/immich/app.guard'; import { sendFile } from 'src/immich/app.utils'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile, } from 'src/immich/interceptors/file-upload.interceptor'; +import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; interface UploadFiles { assetData: ImmichFile[]; diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 78f7fa24fa..613e8f2ed4 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -10,7 +10,6 @@ import { UploadFile } from 'src/domain/asset/asset.service'; import { AssetResponseDto, mapAsset } from 'src/domain/asset/response-dto/asset-response.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; import { mimeTypes } from 'src/domain/domain.constant'; -import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/domain/domain.util'; import { JobName } from 'src/domain/job/job.constants'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { IAssetRepository } from 'src/domain/repositories/asset.repository'; @@ -37,6 +36,7 @@ import { CuratedObjectsResponseDto } from 'src/immich/api-v1/asset/response-dto/ import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/infra/entities/asset.entity'; import { LibraryType } from 'src/infra/entities/library.entity'; import { ImmichLogger } from 'src/infra/logger'; +import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/utils'; import { QueryFailedError } from 'typeorm'; @Injectable() diff --git a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts index 499ec7499a..97d0aa1fa5 100644 --- a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts +++ b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsUUID } from 'class-validator'; -import { Optional, ValidateBoolean, ValidateDate } from 'src/domain/domain.util'; +import { Optional, ValidateBoolean, ValidateDate } from 'src/validation'; export class AssetSearchDto { @ValidateBoolean({ optional: true }) diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts index 90b2d22451..d16a9c05cd 100644 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; import { UploadFieldName } from 'src/domain/asset/asset.service'; -import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/domain/domain.util'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class CreateAssetDto { @ValidateUUID({ optional: true }) diff --git a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts index 65c6a7debb..6c709eb022 100644 --- a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts +++ b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum } from 'class-validator'; -import { Optional } from 'src/domain/domain.util'; +import { Optional } from 'src/validation'; export enum GetAssetThumbnailFormatEnum { JPEG = 'JPEG', diff --git a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts index cc8d8fd15d..8b3147fc2d 100644 --- a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts +++ b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ValidateBoolean } from 'src/domain/domain.util'; +import { ValidateBoolean } from 'src/validation'; export class ServeFileDto { @ValidateBoolean({ optional: true }) diff --git a/server/src/immich/api-v1/validation/file-not-empty-validator.ts b/server/src/immich/api-v1/validation/file-not-empty-validator.ts deleted file mode 100644 index 21f93a952c..0000000000 --- a/server/src/immich/api-v1/validation/file-not-empty-validator.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { FileValidator, Injectable } from '@nestjs/common'; - -@Injectable() -export default class FileNotEmptyValidator extends FileValidator { - constructor(private requiredFields: string[]) { - super({}); - this.requiredFields = requiredFields; - } - - isValid(files?: any): boolean { - if (!files) { - return false; - } - - return this.requiredFields.every((field) => files[field]); - } - - buildErrorMessage(): string { - return `Field(s) ${this.requiredFields.join(', ')} should not be empty`; - } -} diff --git a/server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts b/server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts deleted file mode 100644 index 4329af0112..0000000000 --- a/server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ArgumentMetadata, Injectable, ParseUUIDPipe } from '@nestjs/common'; - -@Injectable() -export class ParseMeUUIDPipe extends ParseUUIDPipe { - async transform(value: string, metadata: ArgumentMetadata) { - if (value == 'me') { - return value; - } - return super.transform(value, metadata); - } -} diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index 8cbb6cf235..f7f1664be5 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -6,13 +6,13 @@ import { join } from 'node:path'; import { AuthService } from 'src/domain/auth/auth.service'; import { DatabaseService } from 'src/domain/database/database.service'; import { ONE_HOUR, WEB_ROOT } from 'src/domain/domain.constant'; -import { OpenGraphTags } from 'src/domain/domain.util'; import { JobService } from 'src/domain/job/job.service'; import { ServerInfoService } from 'src/domain/server-info/server-info.service'; import { SharedLinkService } from 'src/domain/shared-link/shared-link.service'; import { StorageService } from 'src/domain/storage/storage.service'; import { SystemConfigService } from 'src/domain/system-config/system-config.service'; import { ImmichLogger } from 'src/infra/logger'; +import { OpenGraphTags } from 'src/utils'; const render = (index: string, meta: OpenGraphTags) => { const tags = ` diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts index 4d78ffb242..48f01d8dd5 100644 --- a/server/src/immich/app.utils.ts +++ b/server/src/immich/app.utils.ts @@ -15,10 +15,10 @@ import path, { isAbsolute } from 'node:path'; import { promisify } from 'node:util'; import { IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME } from 'src/domain/auth/auth.constant'; import { serverVersion } from 'src/domain/domain.constant'; -import { CacheControl, ImmichFileResponse, isConnectionAborted } from 'src/domain/domain.util'; import { ImmichReadStream } from 'src/domain/repositories/storage.repository'; import { Metadata } from 'src/immich/app.guard'; import { ImmichLogger } from 'src/infra/logger'; +import { CacheControl, ImmichFileResponse, isConnectionAborted } from 'src/utils'; type SendFile = Parameters<Response['sendFile']>; type SendFileOptions = SendFile[1]; diff --git a/server/src/immich/controllers/activity.controller.ts b/server/src/immich/controllers/activity.controller.ts index c79a564077..45f58e9f24 100644 --- a/server/src/immich/controllers/activity.controller.ts +++ b/server/src/immich/controllers/activity.controller.ts @@ -11,7 +11,7 @@ import { import { ActivityService } from 'src/domain/activity/activity.service'; import { AuthDto } from 'src/domain/auth/auth.dto'; import { Auth, Authenticated } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Activity') @Controller('activity') diff --git a/server/src/immich/controllers/album.controller.ts b/server/src/immich/controllers/album.controller.ts index 8981de2124..12e35a0f6d 100644 --- a/server/src/immich/controllers/album.controller.ts +++ b/server/src/immich/controllers/album.controller.ts @@ -9,9 +9,8 @@ import { AlbumInfoDto } from 'src/domain/album/dto/album.dto'; import { GetAlbumsDto } from 'src/domain/album/dto/get-albums.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/domain/asset/response-dto/asset-ids-response.dto'; import { AuthDto } from 'src/domain/auth/auth.dto'; -import { ParseMeUUIDPipe } from 'src/immich/api-v1/validation/parse-me-uuid-pipe'; import { Auth, Authenticated, SharedLinkRoute } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation'; @ApiTags('Album') @Controller('album') diff --git a/server/src/immich/controllers/api-key.controller.ts b/server/src/immich/controllers/api-key.controller.ts index b9b449610c..96afe676dd 100644 --- a/server/src/immich/controllers/api-key.controller.ts +++ b/server/src/immich/controllers/api-key.controller.ts @@ -9,7 +9,7 @@ import { import { APIKeyService } from 'src/domain/api-key/api-key.service'; import { AuthDto } from 'src/domain/auth/auth.dto'; import { Auth, Authenticated } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('API Key') @Controller('api-key') diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index d085dbe570..ee35c98ddc 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -21,8 +21,8 @@ import { AuthDto } from 'src/domain/auth/auth.dto'; import { MetadataSearchDto } from 'src/domain/search/dto/search.dto'; import { SearchService } from 'src/domain/search/search.service'; import { Auth, Authenticated, SharedLinkRoute } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; import { Route } from 'src/immich/interceptors/file-upload.interceptor'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Asset') @Controller('assets') diff --git a/server/src/immich/controllers/auth.controller.ts b/server/src/immich/controllers/auth.controller.ts index b2dece148d..7f9fea47cf 100644 --- a/server/src/immich/controllers/auth.controller.ts +++ b/server/src/immich/controllers/auth.controller.ts @@ -15,7 +15,7 @@ import { import { AuthService, LoginDetails } from 'src/domain/auth/auth.service'; import { UserResponseDto, mapUser } from 'src/domain/user/response-dto/user-response.dto'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Authentication') @Controller('auth') diff --git a/server/src/immich/controllers/download.controller.ts b/server/src/immich/controllers/download.controller.ts index 41849a41d3..0cfaa3ea80 100644 --- a/server/src/immich/controllers/download.controller.ts +++ b/server/src/immich/controllers/download.controller.ts @@ -7,7 +7,7 @@ import { DownloadInfoDto, DownloadResponseDto } from 'src/domain/download/downlo import { DownloadService } from 'src/domain/download/download.service'; import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/immich/app.guard'; import { asStreamableFile, sendFile } from 'src/immich/app.utils'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Download') @Controller('download') diff --git a/server/src/immich/controllers/dto/uuid-param.dto.ts b/server/src/immich/controllers/dto/uuid-param.dto.ts deleted file mode 100644 index 6e1b5a36cc..0000000000 --- a/server/src/immich/controllers/dto/uuid-param.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsUUID } from 'class-validator'; - -export class UUIDParamDto { - @IsNotEmpty() - @IsUUID('4') - @ApiProperty({ format: 'uuid' }) - id!: string; -} diff --git a/server/src/immich/controllers/face.controller.ts b/server/src/immich/controllers/face.controller.ts index 0fab1bb919..e7d1c3fba8 100644 --- a/server/src/immich/controllers/face.controller.ts +++ b/server/src/immich/controllers/face.controller.ts @@ -4,7 +4,7 @@ import { AuthDto } from 'src/domain/auth/auth.dto'; import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/domain/person/person.dto'; import { PersonService } from 'src/domain/person/person.service'; import { Auth, Authenticated } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Face') @Controller('face') diff --git a/server/src/immich/controllers/library.controller.ts b/server/src/immich/controllers/library.controller.ts index d72461988c..5e362f6d0c 100644 --- a/server/src/immich/controllers/library.controller.ts +++ b/server/src/immich/controllers/library.controller.ts @@ -12,7 +12,7 @@ import { } from 'src/domain/library/library.dto'; import { LibraryService } from 'src/domain/library/library.service'; import { AdminRoute, Authenticated } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Library') @Controller('library') diff --git a/server/src/immich/controllers/partner.controller.ts b/server/src/immich/controllers/partner.controller.ts index 43c388e10f..c6bfea3557 100644 --- a/server/src/immich/controllers/partner.controller.ts +++ b/server/src/immich/controllers/partner.controller.ts @@ -5,7 +5,7 @@ import { PartnerResponseDto, UpdatePartnerDto } from 'src/domain/partner/partner import { PartnerService } from 'src/domain/partner/partner.service'; import { PartnerDirection } from 'src/domain/repositories/partner.repository'; import { Auth, Authenticated } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Partner') @Controller('partner') diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index c2b789678b..9ef791c3c9 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -18,7 +18,7 @@ import { import { PersonService } from 'src/domain/person/person.service'; import { Auth, Authenticated, FileResponse } from 'src/immich/app.guard'; import { sendFile } from 'src/immich/app.utils'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Person') @Controller('person') diff --git a/server/src/immich/controllers/shared-link.controller.ts b/server/src/immich/controllers/shared-link.controller.ts index 39e4c084e3..b0717026c4 100644 --- a/server/src/immich/controllers/shared-link.controller.ts +++ b/server/src/immich/controllers/shared-link.controller.ts @@ -9,7 +9,7 @@ import { SharedLinkResponseDto } from 'src/domain/shared-link/shared-link-respon import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from 'src/domain/shared-link/shared-link.dto'; import { SharedLinkService } from 'src/domain/shared-link/shared-link.service'; import { Auth, Authenticated, SharedLinkRoute } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Shared Link') @Controller('shared-link') diff --git a/server/src/immich/controllers/tag.controller.ts b/server/src/immich/controllers/tag.controller.ts index 511d7b1d13..99715d5c72 100644 --- a/server/src/immich/controllers/tag.controller.ts +++ b/server/src/immich/controllers/tag.controller.ts @@ -8,7 +8,7 @@ import { TagResponseDto } from 'src/domain/tag/tag-response.dto'; import { CreateTagDto, UpdateTagDto } from 'src/domain/tag/tag.dto'; import { TagService } from 'src/domain/tag/tag.service'; import { Auth, Authenticated } from 'src/immich/app.guard'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Tag') @Controller('tag') diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 10b16859ff..3f80765a97 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/server/src/immich/controllers/user.controller.ts @@ -26,8 +26,8 @@ import { UserResponseDto } from 'src/domain/user/response-dto/user-response.dto' import { UserService } from 'src/domain/user/user.service'; import { AdminRoute, Auth, Authenticated, FileResponse } from 'src/immich/app.guard'; import { sendFile } from 'src/immich/app.utils'; -import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto'; import { FileUploadInterceptor, Route } from 'src/immich/interceptors/file-upload.interceptor'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('User') @Controller(Route.USER) diff --git a/server/src/immich/interceptors/error.interceptor.ts b/server/src/immich/interceptors/error.interceptor.ts index 809464b374..70c1299224 100644 --- a/server/src/immich/interceptors/error.interceptor.ts +++ b/server/src/immich/interceptors/error.interceptor.ts @@ -7,9 +7,9 @@ import { NestInterceptor, } from '@nestjs/common'; import { Observable, catchError, throwError } from 'rxjs'; -import { isConnectionAborted } from 'src/domain/domain.util'; import { routeToErrorMessage } from 'src/immich/app.utils'; import { ImmichLogger } from 'src/infra/logger'; +import { isConnectionAborted } from 'src/utils'; @Injectable() export class ErrorInterceptor implements NestInterceptor { diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts index adfe476a2f..310ac93023 100644 --- a/server/src/immich/main.ts +++ b/server/src/immich/main.ts @@ -4,11 +4,11 @@ import { json } from 'body-parser'; import cookieParser from 'cookie-parser'; import { existsSync } from 'node:fs'; import sirv from 'sirv'; +import { excludePaths } from 'src/config'; import { WEB_ROOT, envName, isDev, serverVersion } from 'src/domain/domain.constant'; import { AppModule } from 'src/immich/app.module'; import { AppService } from 'src/immich/app.service'; import { useSwagger } from 'src/immich/app.utils'; -import { excludePaths } from 'src/infra/infra.config'; import { otelSDK } from 'src/infra/instrumentation'; import { ImmichLogger } from 'src/infra/logger'; import { WebSocketAdapter } from 'src/infra/websocket.adapter'; diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 7ac208c1fc..b1636c5e2c 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -5,7 +5,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OpenTelemetryModule } from 'nestjs-otel'; -import { immichAppConfig } from 'src/domain/domain.config'; +import { bullConfig, bullQueues, immichAppConfig } from 'src/config'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { IActivityRepository } from 'src/domain/repositories/activity.repository'; import { IAlbumRepository } from 'src/domain/repositories/album.repository'; @@ -35,7 +35,6 @@ import { IUserTokenRepository } from 'src/domain/repositories/user-token.reposit import { IUserRepository } from 'src/domain/repositories/user.repository'; import { databaseConfig } from 'src/infra/database.config'; import { databaseEntities } from 'src/infra/entities'; -import { bullConfig, bullQueues } from 'src/infra/infra.config'; import { otelConfig } from 'src/infra/instrumentation'; import { AccessRepository } from 'src/infra/repositories/access.repository'; import { ActivityRepository } from 'src/infra/repositories/activity.repository'; diff --git a/server/src/infra/infra.util.ts b/server/src/infra/infra.util.ts deleted file mode 100644 index 585d058e03..0000000000 --- a/server/src/infra/infra.util.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const GENERATE_SQL_KEY = 'generate-sql-key'; - -export interface GenerateSqlQueries { - name?: string; - params: unknown[]; -} - -/** Decorator to enable versioning/tracking of generated Sql */ -export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); - -const UUID = '00000000-0000-4000-a000-000000000000'; - -export const DummyValue = { - UUID, - UUID_SET: new Set([UUID]), - PAGINATION: { take: 10, skip: 0 }, - EMAIL: 'user@immich.app', - STRING: 'abcdefghi', - BUFFER: Buffer.from('abcdefghi'), - DATE: new Date(), - TIME_BUCKET: '2024-01-01T00:00:00.000Z', -}; - -// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the -// maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching -// by a list of IDs) requires splitting the query into multiple chunks. -// We are rounding down this limit, as queries commonly include other filters and parameters. -export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500; diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 2cc5f629af..41c57904b6 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -1,16 +1,7 @@ import _ from 'lodash'; -import { - Paginated, - PaginatedBuilderOptions, - PaginationMode, - PaginationOptions, - PaginationResult, - chunks, - setUnion, -} from 'src/domain/domain.util'; import { AssetSearchBuilderOptions } from 'src/domain/repositories/search.repository'; import { AssetEntity } from 'src/infra/entities/asset.entity'; -import { DATABASE_PARAMETER_CHUNK_SIZE } from 'src/infra/infra.util'; +import { Paginated, PaginatedBuilderOptions, PaginationMode, PaginationOptions, PaginationResult } from 'src/utils'; import { Between, FindManyOptions, @@ -37,11 +28,6 @@ export function OptionalBetween<T>(from?: T, to?: T) { } } -export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => { - const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options; - return Number.isInteger(value) && value >= min && value <= max; -}; - function paginationHelper<Entity extends ObjectLiteral>(items: Entity[], take: number): PaginationResult<Entity> { const hasNextPage = items.length > take; items.splice(take); @@ -86,70 +72,6 @@ export async function paginatedBuilder<Entity extends ObjectLiteral>( export const asVector = (embedding: number[], quote = false) => quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`; -/** - * Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection, - * to overcome the maximum number of parameters allowed by the database driver. - * - * @param options.paramIndex The index of the function parameter to chunk. Defaults to 0. - * @param options.flatten Whether to flatten the results. Defaults to false. - */ -export function Chunked(options: { paramIndex?: number; mergeFn?: (results: any) => any } = {}): MethodDecorator { - return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - const originalMethod = descriptor.value; - const parameterIndex = options.paramIndex ?? 0; - descriptor.value = async function (...arguments_: any[]) { - const argument = arguments_[parameterIndex]; - - // Early return if argument length is less than or equal to the chunk size. - if ( - (Array.isArray(argument) && argument.length <= DATABASE_PARAMETER_CHUNK_SIZE) || - (argument instanceof Set && argument.size <= DATABASE_PARAMETER_CHUNK_SIZE) - ) { - return await originalMethod.apply(this, arguments_); - } - - return Promise.all( - chunks(argument, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => { - await Reflect.apply(originalMethod, this, [ - ...arguments_.slice(0, parameterIndex), - chunk, - ...arguments_.slice(parameterIndex + 1), - ]); - }), - ).then((results) => (options.mergeFn ? options.mergeFn(results) : results)); - }; - }; -} - -export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator { - return Chunked({ ...options, mergeFn: _.flatten }); -} - -export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator { - return Chunked({ ...options, mergeFn: setUnion }); -} - -// https://stackoverflow.com/a/74898678 -export function DecorateAll( - decorator: <T>( - target: any, - propertyKey: string, - descriptor: TypedPropertyDescriptor<T>, - ) => TypedPropertyDescriptor<T> | void, -) { - return (target: any) => { - const descriptors = Object.getOwnPropertyDescriptors(target.prototype); - for (const [propName, descriptor] of Object.entries(descriptors)) { - const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor'; - if (!isMethod) { - continue; - } - decorator({ ...target, constructor: { ...target.constructor, name: target.name } as any }, propName, descriptor); - Object.defineProperty(target.prototype, propName, descriptor); - } - }; -} - export function searchAssetBuilder( builder: SelectQueryBuilder<AssetEntity>, options: AssetSearchBuilderOptions, diff --git a/server/src/infra/instrumentation.ts b/server/src/infra/instrumentation.ts index 2c912660ee..a30f0523a6 100644 --- a/server/src/infra/instrumentation.ts +++ b/server/src/infra/instrumentation.ts @@ -13,9 +13,9 @@ import { snakeCase, startCase } from 'lodash'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils'; import { performance } from 'node:perf_hooks'; +import { excludePaths } from 'src/config'; +import { DecorateAll } from 'src/decorators'; import { serverVersion } from 'src/domain/domain.constant'; -import { excludePaths } from 'src/infra/infra.config'; -import { DecorateAll } from 'src/infra/infra.utils'; let metricsEnabled = process.env.IMMICH_METRICS === 'true'; const hostMetrics = diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 1f96226a6f..d16d6a6aa9 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -1,4 +1,5 @@ import { InjectRepository } from '@nestjs/typeorm'; +import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { IAccessRepository } from 'src/domain/repositories/access.repository'; import { ActivityEntity } from 'src/infra/entities/activity.entity'; import { AlbumEntity } from 'src/infra/entities/album.entity'; @@ -9,8 +10,6 @@ import { PartnerEntity } from 'src/infra/entities/partner.entity'; import { PersonEntity } from 'src/infra/entities/person.entity'; import { SharedLinkEntity } from 'src/infra/entities/shared-link.entity'; import { UserTokenEntity } from 'src/infra/entities/user-token.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; -import { ChunkedSet } from 'src/infra/infra.utils'; import { Instrumentation } from 'src/infra/instrumentation'; import { Brackets, In, Repository } from 'typeorm'; diff --git a/server/src/infra/repositories/activity.repository.ts b/server/src/infra/repositories/activity.repository.ts index afa52ec3a4..1cfe3e264c 100644 --- a/server/src/infra/repositories/activity.repository.ts +++ b/server/src/infra/repositories/activity.repository.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { IActivityRepository } from 'src/domain/repositories/activity.repository'; import { ActivityEntity } from 'src/infra/entities/activity.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; import { Instrumentation } from 'src/infra/instrumentation'; import { IsNull, Repository } from 'typeorm'; diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index 86b73866f4..b31a894b5e 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; -import { setUnion } from 'src/domain/domain.util'; +import { Chunked, ChunkedArray, DATABASE_PARAMETER_CHUNK_SIZE, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumAsset, AlbumAssetCount, @@ -12,9 +12,8 @@ import { import { dataSource } from 'src/infra/database.config'; import { AlbumEntity } from 'src/infra/entities/album.entity'; import { AssetEntity } from 'src/infra/entities/asset.entity'; -import { DATABASE_PARAMETER_CHUNK_SIZE, DummyValue, GenerateSql } from 'src/infra/infra.util'; -import { Chunked, ChunkedArray } from 'src/infra/infra.utils'; import { Instrumentation } from 'src/infra/instrumentation'; +import { setUnion } from 'src/utils'; import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; @Instrumentation() diff --git a/server/src/infra/repositories/api-key.repository.ts b/server/src/infra/repositories/api-key.repository.ts index bdc9e6d0bf..5bacfded5f 100644 --- a/server/src/infra/repositories/api-key.repository.ts +++ b/server/src/infra/repositories/api-key.repository.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { IKeyRepository } from 'src/domain/repositories/api-key.repository'; import { APIKeyEntity } from 'src/infra/entities/api-key.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; import { Instrumentation } from 'src/infra/instrumentation'; import { Repository } from 'typeorm'; diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 93f2e42a18..a57e852d28 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DateTime } from 'luxon'; import path from 'node:path'; -import { Paginated, PaginationMode, PaginationOptions } from 'src/domain/domain.util'; +import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetBuilderOptions, AssetCreate, @@ -30,16 +30,9 @@ import { AssetJobStatusEntity } from 'src/infra/entities/asset-job-status.entity import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity'; import { ExifEntity } from 'src/infra/entities/exif.entity'; import { SmartInfoEntity } from 'src/infra/entities/smart-info.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; -import { - Chunked, - ChunkedArray, - OptionalBetween, - paginate, - paginatedBuilder, - searchAssetBuilder, -} from 'src/infra/infra.utils'; +import { OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from 'src/infra/infra.utils'; import { Instrumentation } from 'src/infra/instrumentation'; +import { Paginated, PaginationMode, PaginationOptions } from 'src/utils'; import { Brackets, FindOptionsRelations, diff --git a/server/src/infra/repositories/database.repository.ts b/server/src/infra/repositories/database.repository.ts index a83bcf80f8..8720ad8d70 100644 --- a/server/src/infra/repositories/database.repository.ts +++ b/server/src/infra/repositories/database.repository.ts @@ -12,9 +12,9 @@ import { extName, } from 'src/domain/repositories/database.repository'; import { vectorExt } from 'src/infra/database.config'; -import { isValidInteger } from 'src/infra/infra.utils'; import { Instrumentation } from 'src/infra/instrumentation'; import { ImmichLogger } from 'src/infra/logger'; +import { isValidInteger } from 'src/validation'; import { DataSource, EntityManager, QueryRunner } from 'typeorm'; @Instrumentation() diff --git a/server/src/infra/repositories/job.repository.ts b/server/src/infra/repositories/job.repository.ts index eac535e361..856c7fae06 100644 --- a/server/src/infra/repositories/job.repository.ts +++ b/server/src/infra/repositories/job.repository.ts @@ -5,6 +5,7 @@ import { SchedulerRegistry } from '@nestjs/schedule'; import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq'; import { CronJob, CronTime } from 'cron'; import { setTimeout } from 'node:timers/promises'; +import { bullConfig } from 'src/config'; import { JOBS_TO_QUEUE, JobName, QueueName } from 'src/domain/job/job.constants'; import { IJobRepository, @@ -13,7 +14,6 @@ import { QueueCleanType, QueueStatus, } from 'src/domain/repositories/job.repository'; -import { bullConfig } from 'src/infra/infra.config'; import { Instrumentation } from 'src/infra/instrumentation'; import { ImmichLogger } from 'src/infra/logger'; diff --git a/server/src/infra/repositories/library.repository.ts b/server/src/infra/repositories/library.repository.ts index f61d4beb6e..f9045b9c88 100644 --- a/server/src/infra/repositories/library.repository.ts +++ b/server/src/infra/repositories/library.repository.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { LibraryStatsResponseDto } from 'src/domain/library/library.dto'; import { ILibraryRepository } from 'src/domain/repositories/library.repository'; import { LibraryEntity, LibraryType } from 'src/infra/entities/library.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; import { Instrumentation } from 'src/infra/instrumentation'; import { IsNull, Not } from 'typeorm'; import { Repository } from 'typeorm/repository/Repository.js'; diff --git a/server/src/infra/repositories/media.repository.ts b/server/src/infra/repositories/media.repository.ts index 0088e312f2..f2d59ea95d 100644 --- a/server/src/infra/repositories/media.repository.ts +++ b/server/src/infra/repositories/media.repository.ts @@ -3,7 +3,6 @@ import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; import { promisify } from 'node:util'; import sharp from 'sharp'; -import { handlePromiseError } from 'src/domain/domain.util'; import { CropOptions, IMediaRepository, @@ -14,6 +13,7 @@ import { import { Colorspace } from 'src/infra/entities/system-config.entity'; import { Instrumentation } from 'src/infra/instrumentation'; import { ImmichLogger } from 'src/infra/logger'; +import { handlePromiseError } from 'src/utils'; const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe); sharp.concurrency(0); diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts index e81ba8dcbe..365a3543f5 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -6,6 +6,7 @@ import { getName } from 'i18n-iso-countries'; import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import readLine from 'node:readline'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { citiesFile, geodataAdmin1Path, @@ -23,7 +24,6 @@ import { ISystemMetadataRepository } from 'src/domain/repositories/system-metada import { ExifEntity } from 'src/infra/entities/exif.entity'; import { GeodataPlacesEntity } from 'src/infra/entities/geodata-places.entity'; import { SystemMetadataKey } from 'src/infra/entities/system-metadata.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; import { Instrumentation } from 'src/infra/instrumentation'; import { ImmichLogger } from 'src/infra/logger'; import { DataSource, QueryRunner, Repository } from 'typeorm'; diff --git a/server/src/infra/repositories/move.repository.ts b/server/src/infra/repositories/move.repository.ts index 04807db248..8482fc228b 100644 --- a/server/src/infra/repositories/move.repository.ts +++ b/server/src/infra/repositories/move.repository.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { IMoveRepository, MoveCreate } from 'src/domain/repositories/move.repository'; import { MoveEntity, PathType } from 'src/infra/entities/move.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; import { Instrumentation } from 'src/infra/instrumentation'; import { Repository } from 'typeorm'; diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index df76ad9d0d..e0d707ca1b 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -1,6 +1,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; -import { Paginated, PaginationOptions } from 'src/domain/domain.util'; +import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceId, IPersonRepository, @@ -13,9 +13,9 @@ import { import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity'; import { AssetEntity } from 'src/infra/entities/asset.entity'; import { PersonEntity } from 'src/infra/entities/person.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; -import { ChunkedArray, asVector, paginate } from 'src/infra/infra.utils'; +import { asVector, paginate } from 'src/infra/infra.utils'; import { Instrumentation } from 'src/infra/instrumentation'; +import { Paginated, PaginationOptions } from 'src/utils'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; @Instrumentation() diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 5676bd8b53..6ba546ea9a 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Paginated, PaginationMode, PaginationResult } from 'src/domain/domain.util'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { DatabaseExtension } from 'src/domain/repositories/database.repository'; import { AssetSearchOptions, @@ -18,10 +18,11 @@ import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/infra/entities/geodata-places.entity'; import { SmartInfoEntity } from 'src/infra/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/infra/entities/smart-search.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; -import { asVector, isValidInteger, paginatedBuilder, searchAssetBuilder } from 'src/infra/infra.utils'; +import { asVector, paginatedBuilder, searchAssetBuilder } from 'src/infra/infra.utils'; import { Instrumentation } from 'src/infra/instrumentation'; import { ImmichLogger } from 'src/infra/logger'; +import { Paginated, PaginationMode, PaginationResult } from 'src/utils'; +import { isValidInteger } from 'src/validation'; import { Repository, SelectQueryBuilder } from 'typeorm'; @Instrumentation() diff --git a/server/src/infra/repositories/shared-link.repository.ts b/server/src/infra/repositories/shared-link.repository.ts index 1b1ed7fe2a..e2878acf4d 100644 --- a/server/src/infra/repositories/shared-link.repository.ts +++ b/server/src/infra/repositories/shared-link.repository.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { ISharedLinkRepository } from 'src/domain/repositories/shared-link.repository'; import { SharedLinkEntity } from 'src/infra/entities/shared-link.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; import { Instrumentation } from 'src/infra/instrumentation'; import { Repository } from 'typeorm'; diff --git a/server/src/infra/repositories/system-config.repository.ts b/server/src/infra/repositories/system-config.repository.ts index f354c87e67..555a3c254c 100644 --- a/server/src/infra/repositories/system-config.repository.ts +++ b/server/src/infra/repositories/system-config.repository.ts @@ -1,9 +1,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { readFile } from 'node:fs/promises'; +import { Chunked, DummyValue, GenerateSql } from 'src/decorators'; import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository'; import { SystemConfigEntity } from 'src/infra/entities/system-config.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; -import { Chunked } from 'src/infra/infra.utils'; import { Instrumentation } from 'src/infra/instrumentation'; import { In, Repository } from 'typeorm'; diff --git a/server/src/infra/repositories/user-token.repository.ts b/server/src/infra/repositories/user-token.repository.ts index f2b79a05f2..7d16bd0cff 100644 --- a/server/src/infra/repositories/user-token.repository.ts +++ b/server/src/infra/repositories/user-token.repository.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { IUserTokenRepository } from 'src/domain/repositories/user-token.repository'; import { UserTokenEntity } from 'src/infra/entities/user-token.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; import { Instrumentation } from 'src/infra/instrumentation'; import { Repository } from 'typeorm'; diff --git a/server/src/infra/repositories/user.repository.ts b/server/src/infra/repositories/user.repository.ts index e71d629360..27692dd660 100644 --- a/server/src/infra/repositories/user.repository.ts +++ b/server/src/infra/repositories/user.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { IUserRepository, UserFindOptions, @@ -8,7 +9,6 @@ import { } from 'src/domain/repositories/user.repository'; import { AssetEntity } from 'src/infra/entities/asset.entity'; import { UserEntity } from 'src/infra/entities/user.entity'; -import { DummyValue, GenerateSql } from 'src/infra/infra.util'; import { Instrumentation } from 'src/infra/instrumentation'; import { IsNull, Not, Repository } from 'typeorm'; diff --git a/server/src/infra/sql-generator/index.ts b/server/src/infra/sql-generator/index.ts index c365587a96..0821d53cd3 100644 --- a/server/src/infra/sql-generator/index.ts +++ b/server/src/infra/sql-generator/index.ts @@ -5,10 +5,10 @@ import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators'; import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository'; import { databaseConfig } from 'src/infra/database.config'; import { databaseEntities } from 'src/infra/entities'; -import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/infra/infra.util'; import { AccessRepository } from 'src/infra/repositories/access.repository'; import { AlbumRepository } from 'src/infra/repositories/album.repository'; import { ApiKeyRepository } from 'src/infra/repositories/api-key.repository'; diff --git a/server/src/microservices/utils/exif/coordinates.ts b/server/src/microservices/utils/exif/coordinates.ts deleted file mode 100644 index 7e0d816794..0000000000 --- a/server/src/microservices/utils/exif/coordinates.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { isNumberInRange } from 'src/microservices/utils/numbers'; - -export function parseLatitude(input: string | number | null): number | null { - if (input === null) { - return null; - } - const latitude = typeof input === 'string' ? Number.parseFloat(input) : input; - - if (isNumberInRange(latitude, -90, 90)) { - return latitude; - } - return null; -} - -export function parseLongitude(input: string | number | null): number | null { - if (input === null) { - return null; - } - - const longitude = typeof input === 'string' ? Number.parseFloat(input) : input; - - if (isNumberInRange(longitude, -180, 180)) { - return longitude; - } - return null; -} diff --git a/server/src/microservices/utils/numbers.spec.ts b/server/src/microservices/utils/numbers.spec.ts deleted file mode 100644 index 633b8b1d45..0000000000 --- a/server/src/microservices/utils/numbers.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { isDecimalNumber, isNumberInRange, toNumberOrNull } from 'src/microservices/utils/numbers'; - -describe('checks if a number is a decimal number', () => { - it('returns false for non-decimal numbers', () => { - expect(isDecimalNumber(Number.NaN)).toBe(false); - expect(isDecimalNumber(Number.POSITIVE_INFINITY)).toBe(false); - expect(isDecimalNumber(Number.NEGATIVE_INFINITY)).toBe(false); - }); - - it('returns true for decimal numbers', () => { - expect(isDecimalNumber(0)).toBe(true); - expect(isDecimalNumber(-0)).toBe(true); - expect(isDecimalNumber(10.123_45)).toBe(true); - expect(isDecimalNumber(Number.MAX_VALUE)).toBe(true); - expect(isDecimalNumber(Number.MIN_VALUE)).toBe(true); - }); -}); - -describe('checks if a number is within a range', () => { - it('returns false for numbers outside the range', () => { - expect(isNumberInRange(0, 10, 10)).toBe(false); - expect(isNumberInRange(0.01, 10, 10)).toBe(false); - expect(isNumberInRange(50.1, 0, 50)).toBe(false); - }); - - it('returns true for numbers inside the range', () => { - expect(isNumberInRange(0, 0, 50)).toBe(true); - expect(isNumberInRange(50, 0, 50)).toBe(true); - expect(isNumberInRange(-50.123_45, -50.123_45, 0)).toBe(true); - }); -}); - -describe('converts input to a number or null', () => { - it('returns null for invalid inputs', () => { - expect(toNumberOrNull(null)).toBeNull(); - // eslint-disable-next-line unicorn/no-useless-undefined - expect(toNumberOrNull(undefined)).toBeNull(); - expect(toNumberOrNull('')).toBeNull(); - expect(toNumberOrNull(Number.NaN)).toBeNull(); - }); - - it('returns a number for valid inputs', () => { - expect(toNumberOrNull(0)).toBeCloseTo(0); - expect(toNumberOrNull('0')).toBeCloseTo(0); - expect(toNumberOrNull('-123.45')).toBeCloseTo(-123.45); - }); -}); diff --git a/server/src/microservices/utils/numbers.ts b/server/src/microservices/utils/numbers.ts deleted file mode 100644 index cd6e81d2a2..0000000000 --- a/server/src/microservices/utils/numbers.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function isDecimalNumber(number_: number): boolean { - return !Number.isNaN(number_) && Number.isFinite(number_); -} - -/** - * Check if `num` is a valid number and is between `start` and `end` (inclusive) - */ -export function isNumberInRange(number_: number, start: number, end: number): boolean { - return isDecimalNumber(number_) && number_ >= start && number_ <= end; -} - -export function toNumberOrNull(input: number | string | null | undefined): number | null { - if (input === null || input === undefined) { - return null; - } - - const number_ = typeof input === 'string' ? Number.parseFloat(input) : input; - return isDecimalNumber(number_) ? number_ : null; -} diff --git a/server/src/microservices/utils/exif/coordinates.spec.ts b/server/src/utils.spec.ts similarity index 53% rename from server/src/microservices/utils/exif/coordinates.spec.ts rename to server/src/utils.spec.ts index 083ea31bc4..c5ae5f6e57 100644 --- a/server/src/microservices/utils/exif/coordinates.spec.ts +++ b/server/src/utils.spec.ts @@ -1,4 +1,50 @@ -import { parseLatitude, parseLongitude } from 'src/microservices/utils/exif/coordinates'; +import { isDecimalNumber, isNumberInRange, parseLatitude, parseLongitude, toNumberOrNull } from 'src/utils'; + +describe('checks if a number is a decimal number', () => { + it('returns false for non-decimal numbers', () => { + expect(isDecimalNumber(Number.NaN)).toBe(false); + expect(isDecimalNumber(Number.POSITIVE_INFINITY)).toBe(false); + expect(isDecimalNumber(Number.NEGATIVE_INFINITY)).toBe(false); + }); + + it('returns true for decimal numbers', () => { + expect(isDecimalNumber(0)).toBe(true); + expect(isDecimalNumber(-0)).toBe(true); + expect(isDecimalNumber(10.123_45)).toBe(true); + expect(isDecimalNumber(Number.MAX_VALUE)).toBe(true); + expect(isDecimalNumber(Number.MIN_VALUE)).toBe(true); + }); +}); + +describe('checks if a number is within a range', () => { + it('returns false for numbers outside the range', () => { + expect(isNumberInRange(0, 10, 10)).toBe(false); + expect(isNumberInRange(0.01, 10, 10)).toBe(false); + expect(isNumberInRange(50.1, 0, 50)).toBe(false); + }); + + it('returns true for numbers inside the range', () => { + expect(isNumberInRange(0, 0, 50)).toBe(true); + expect(isNumberInRange(50, 0, 50)).toBe(true); + expect(isNumberInRange(-50.123_45, -50.123_45, 0)).toBe(true); + }); +}); + +describe('converts input to a number or null', () => { + it('returns null for invalid inputs', () => { + expect(toNumberOrNull(null)).toBeNull(); + // eslint-disable-next-line unicorn/no-useless-undefined + expect(toNumberOrNull(undefined)).toBeNull(); + expect(toNumberOrNull('')).toBeNull(); + expect(toNumberOrNull(Number.NaN)).toBeNull(); + }); + + it('returns a number for valid inputs', () => { + expect(toNumberOrNull(0)).toBeCloseTo(0); + expect(toNumberOrNull('0')).toBeCloseTo(0); + expect(toNumberOrNull('-123.45')).toBeCloseTo(-123.45); + }); +}); describe('parsing latitude from string input', () => { it('returns null for invalid inputs', () => { diff --git a/server/src/utils.ts b/server/src/utils.ts new file mode 100644 index 0000000000..863dde7ca8 --- /dev/null +++ b/server/src/utils.ts @@ -0,0 +1,181 @@ +import { basename, extname } from 'node:path'; +import { ImmichLogger } from 'src/infra/logger'; + +const KiB = Math.pow(1024, 1); +const MiB = Math.pow(1024, 2); +const GiB = Math.pow(1024, 3); +const TiB = Math.pow(1024, 4); +const PiB = Math.pow(1024, 5); + +export const HumanReadableSize = { KiB, MiB, GiB, TiB, PiB }; + +export function asHumanReadable(bytes: number, precision = 1): string { + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB']; + + let magnitude = 0; + let remainder = bytes; + while (remainder >= 1024) { + if (magnitude + 1 < units.length) { + magnitude++; + remainder /= 1024; + } else { + break; + } + } + + return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`; +} + +export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; + +export function isDecimalNumber(number_: number): boolean { + return !Number.isNaN(number_) && Number.isFinite(number_); +} + +/** + * Check if `num` is a valid number and is between `start` and `end` (inclusive) + */ +export function isNumberInRange(number_: number, start: number, end: number): boolean { + return isDecimalNumber(number_) && number_ >= start && number_ <= end; +} + +export function toNumberOrNull(input: number | string | null | undefined): number | null { + if (input === null || input === undefined) { + return null; + } + + const number_ = typeof input === 'string' ? Number.parseFloat(input) : input; + return isDecimalNumber(number_) ? number_ : null; +} + +export function parseLatitude(input: string | number | null): number | null { + if (input === null) { + return null; + } + const latitude = typeof input === 'string' ? Number.parseFloat(input) : input; + + if (isNumberInRange(latitude, -90, 90)) { + return latitude; + } + return null; +} + +export function parseLongitude(input: string | number | null): number | null { + if (input === null) { + return null; + } + + const longitude = typeof input === 'string' ? Number.parseFloat(input) : input; + + if (isNumberInRange(longitude, -180, 180)) { + return longitude; + } + return null; +} + +// NOTE: The following Set utils have been added here, to easily determine where they are used. +// They should be replaced with native Set operations, when they are added to the language. +// Proposal reference: https://github.com/tc39/proposal-set-methods + +export const setUnion = <T>(...sets: Set<T>[]): Set<T> => { + const union = new Set(sets[0]); + for (const set of sets.slice(1)) { + for (const element of set) { + union.add(element); + } + } + return union; +}; + +export const setDifference = <T>(setA: Set<T>, ...sets: Set<T>[]): Set<T> => { + const difference = new Set(setA); + for (const set of sets) { + for (const element of set) { + difference.delete(element); + } + } + return difference; +}; + +export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => { + for (const element of subset) { + if (!set.has(element)) { + return false; + } + } + return true; +}; + +export const setIsEqual = <T>(setA: Set<T>, setB: Set<T>): boolean => { + return setA.size === setB.size && setIsSuperset(setA, setB); +}; + +export const handlePromiseError = <T>(promise: Promise<T>, logger: ImmichLogger): void => { + promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack)); +}; + +export enum CacheControl { + PRIVATE_WITH_CACHE = 'private_with_cache', + PRIVATE_WITHOUT_CACHE = 'private_without_cache', + NONE = 'none', +} + +export class ImmichFileResponse { + public readonly path!: string; + public readonly contentType!: string; + public readonly cacheControl!: CacheControl; + + constructor(response: ImmichFileResponse) { + Object.assign(this, response); + } +} + +export interface OpenGraphTags { + title: string; + description: string; + imageUrl?: string; +} + +export function getFileNameWithoutExtension(path: string): string { + return basename(path, extname(path)); +} + +export function getLivePhotoMotionFilename(stillName: string, motionName: string) { + return getFileNameWithoutExtension(stillName) + extname(motionName); +} + +export interface PaginationOptions { + take: number; + skip?: number; +} + +export enum PaginationMode { + LIMIT_OFFSET = 'limit-offset', + SKIP_TAKE = 'skip-take', +} + +export interface PaginatedBuilderOptions { + take: number; + skip?: number; + mode?: PaginationMode; +} + +export interface PaginationResult<T> { + items: T[]; + hasNextPage: boolean; +} + +export type Paginated<T> = Promise<PaginationResult<T>>; + +export async function* usePagination<T>( + pageSize: number, + getNextPage: (pagination: PaginationOptions) => PaginationResult<T> | Paginated<T>, +) { + let hasNextPage = true; + + for (let skip = 0; hasNextPage; skip += pageSize) { + const result = await getNextPage({ take: pageSize, skip }); + hasNextPage = result.hasNextPage; + yield result.items; + } +} diff --git a/server/src/validation.ts b/server/src/validation.ts new file mode 100644 index 0000000000..bc1dbae819 --- /dev/null +++ b/server/src/validation.ts @@ -0,0 +1,164 @@ +import { + ArgumentMetadata, + BadRequestException, + FileValidator, + Injectable, + ParseUUIDPipe, + applyDecorators, +} from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsDate, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + ValidateIf, + ValidationOptions, + isDateString, +} from 'class-validator'; +import { CronJob } from 'cron'; +import sanitize from 'sanitize-filename'; + +@Injectable() +export class ParseMeUUIDPipe extends ParseUUIDPipe { + async transform(value: string, metadata: ArgumentMetadata) { + if (value == 'me') { + return value; + } + return super.transform(value, metadata); + } +} + +@Injectable() +export class FileNotEmptyValidator extends FileValidator { + constructor(private requiredFields: string[]) { + super({}); + this.requiredFields = requiredFields; + } + + isValid(files?: any): boolean { + if (!files) { + return false; + } + + return this.requiredFields.every((field) => files[field]); + } + + buildErrorMessage(): string { + return `Field(s) ${this.requiredFields.join(', ')} should not be empty`; + } +} + +export class UUIDParamDto { + @IsNotEmpty() + @IsUUID('4') + @ApiProperty({ format: 'uuid' }) + id!: string; +} + +export interface OptionalOptions extends ValidationOptions { + nullable?: boolean; +} + +/** + * Checks if value is missing and if so, ignores all validators. + * + * @param validationOptions {@link OptionalOptions} + * + * @see IsOptional exported from `class-validator. + */ +// https://stackoverflow.com/a/71353929 +export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) { + if (nullable === true) { + return IsOptional(validationOptions); + } + + return ValidateIf((object: any, v: any) => v !== undefined, validationOptions); +} + +type UUIDOptions = { optional?: boolean; each?: boolean }; +export const ValidateUUID = (options?: UUIDOptions) => { + const { optional, each } = { optional: false, each: false, ...options }; + return applyDecorators( + IsUUID('4', { each }), + ApiProperty({ format: 'uuid' }), + optional ? Optional() : IsNotEmpty(), + each ? IsArray() : IsString(), + ); +}; + +type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; +export const ValidateDate = (options?: DateOptions) => { + const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options }; + + const decorators = [ + ApiProperty({ format }), + IsDate(), + optional ? Optional({ nullable: true }) : IsNotEmpty(), + Transform(({ key, value }) => { + if (value === null || value === undefined) { + return value; + } + + if (!isDateString(value)) { + throw new BadRequestException(`${key} must be a date string`); + } + + return new Date(value as string); + }), + ]; + + if (optional) { + decorators.push(Optional({ nullable })); + } + + return applyDecorators(...decorators); +}; + +type BooleanOptions = { optional?: boolean }; +export const ValidateBoolean = (options?: BooleanOptions) => { + const { optional } = { optional: false, ...options }; + const decorators = [ + // ApiProperty(), + IsBoolean(), + Transform(({ value }) => { + if (value == 'true') { + return true; + } else if (value == 'false') { + return false; + } + return value; + }), + ]; + + if (optional) { + decorators.push(Optional()); + } + + return applyDecorators(...decorators); +}; + +export function validateCronExpression(expression: string) { + try { + new CronJob(expression, () => {}); + } catch { + return false; + } + + return true; +} + +type IValue = { value: string }; + +export const toEmail = ({ value }: IValue) => value?.toLowerCase(); + +export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', '')); + +export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => { + const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options; + return Number.isInteger(value) && value >= min && value <= max; +};