diff --git a/server/src/app.module.ts b/server/src/app.module.ts index cd19972206..0096cc6c26 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -3,9 +3,11 @@ import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; +import postgres from 'postgres'; import { commands } from 'src/commands'; import { IWorker } from 'src/constants'; import { controllers } from 'src/controllers'; @@ -57,7 +59,19 @@ const imports = [ }, }), TypeOrmModule.forFeature(entities), - KyselyModule.forRoot(database.config.kysely), + KyselyModule.forRoot({ + dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }), + log(event) { + if (event.level === 'error') { + console.error('Query failed :', { + durationMs: event.queryDurationMillis, + error: event.error, + sql: event.query.sql, + params: event.query.parameters, + }); + } + }, + }), ]; class BaseModule implements OnModuleInit, OnModuleDestroy { diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index c25e1c8a90..e0d578d58f 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -4,10 +4,12 @@ import { Reflector } from '@nestjs/core'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import postgres from 'postgres'; import { format } from 'sql-formatter'; import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators'; import { entities } from 'src/entities'; @@ -84,7 +86,7 @@ class SqlGenerator { const moduleFixture = await Test.createTestingModule({ imports: [ KyselyModule.forRoot({ - ...database.config.kysely, + dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }), log: (event) => { if (event.level === 'query') { this.sqlLogger.logQuery(event.query.sql); diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 19068ddc5d..2b5343f7ba 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -1,4 +1,3 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; import { ImmichTelemetry } from 'src/enum'; import { clearEnvCache, ConfigRepository } from 'src/repositories/config.repository'; @@ -81,10 +80,13 @@ describe('getEnv', () => { const { database } = getEnv(); expect(database).toEqual({ config: { - kysely: { - dialect: expect.any(PostgresJSDialect), - log: expect.any(Function), - }, + kysely: expect.objectContaining({ + host: 'database', + port: 5432, + database: 'immich', + username: 'postgres', + password: 'postgres', + }), typeorm: expect.objectContaining({ type: 'postgres', host: 'database', @@ -104,6 +106,72 @@ describe('getEnv', () => { const { database } = getEnv(); expect(database).toMatchObject({ skipMigrations: true }); }); + + it('should use DB_URL', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich'; + const { database } = getEnv(); + expect(database.config.kysely).toMatchObject({ + host: 'database1', + password: 'postgres2', + user: 'postgres1', + port: 54_320, + database: 'immich', + }); + }); + + it('should handle sslmode=require', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=prefer', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=verify-ca', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=verify-full', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=no-verify', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } }); + }); + + it('should handle ssl=true', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: true }); + }); + + it('should reject invalid ssl', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid'; + + expect(() => getEnv()).toThrowError('Invalid ssl option: invalid'); + }); }); describe('redis', () => { diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index d78e473da2..a2af1b61b3 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -5,12 +5,11 @@ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { Request, Response } from 'express'; import { RedisOptions } from 'ioredis'; -import { KyselyConfig } from 'kysely'; -import { PostgresJSDialect } from 'kysely-postgres-js'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { join, resolve } from 'node:path'; -import postgres, { Notice } from 'postgres'; +import { parse } from 'pg-connection-string'; +import { Notice } from 'postgres'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; import { EnvDto } from 'src/dtos/env.dto'; @@ -20,6 +19,20 @@ import { QueueName } from 'src/interfaces/job.interface'; import { setDifference } from 'src/utils/set'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; +type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; +type PostgresConnectionConfig = { + host?: string; + password?: string; + user?: string; + port?: number; + database?: string; + client_encoding?: string; + ssl?: Ssl; + application_name?: string; + fallback_application_name?: string; + options?: string; +}; + export interface EnvData { host?: string; port: number; @@ -53,7 +66,7 @@ export interface EnvData { }; database: { - config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: KyselyConfig }; + config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig }; skipMigrations: boolean; vectorExtension: VectorExtension; }; @@ -124,6 +137,9 @@ const asSet = (value: string | undefined, defaults: T[]) => { return new Set(values.length === 0 ? defaults : (values as T[])); }; +const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl => + typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full'; + const getEnv = (): EnvData => { const dto = plainToInstance(EnvDto, process.env); const errors = validateSync(dto); @@ -185,6 +201,31 @@ const getEnv = (): EnvData => { } } + const parts = { + connectionType: 'parts', + host: dto.DB_HOSTNAME || 'database', + port: dto.DB_PORT || 5432, + username: dto.DB_USERNAME || 'postgres', + password: dto.DB_PASSWORD || 'postgres', + database: dto.DB_DATABASE_NAME || 'immich', + } as const; + + let parsedOptions: PostgresConnectionConfig = parts; + if (dto.DB_URL) { + const parsed = parse(dto.DB_URL); + if (!isValidSsl(parsed.ssl)) { + throw new Error(`Invalid ssl option: ${parsed.ssl}`); + } + + parsedOptions = { + ...parsed, + ssl: parsed.ssl, + host: parsed.host ?? undefined, + port: parsed.port ? Number(parsed.port) : undefined, + database: parsed.database ?? undefined, + }; + } + const driverOptions = { onnotice: (notice: Notice) => { if (notice['severity'] !== 'NOTICE') { @@ -206,17 +247,9 @@ const getEnv = (): EnvData => { serialize: (value: number) => value.toString(), }, }, + ...parsedOptions, }; - const parts = { - connectionType: 'parts', - host: dto.DB_HOSTNAME || 'database', - port: dto.DB_PORT || 5432, - username: dto.DB_USERNAME || 'postgres', - password: dto.DB_PASSWORD || 'postgres', - database: dto.DB_DATABASE_NAME || 'immich', - } as const; - return { host: dto.IMMICH_HOST, port: dto.IMMICH_PORT || 2283, @@ -282,21 +315,7 @@ const getEnv = (): EnvData => { parseInt8: true, ...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts), }, - kysely: { - dialect: new PostgresJSDialect({ - postgres: databaseUrl ? postgres(databaseUrl, driverOptions) : postgres({ ...parts, ...driverOptions }), - }), - log(event) { - if (event.level === 'error') { - console.error('Query failed :', { - durationMs: event.queryDurationMillis, - error: event.error, - sql: event.query.sql, - params: event.query.parameters, - }); - } - }, - }, + kysely: driverOptions, }, skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index 477cb6931f..edd2f9dc62 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,4 +1,3 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; import { DatabaseExtension, EXTENSION_NAMES, @@ -62,8 +61,11 @@ describe(DatabaseService.name, () => { database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', @@ -298,8 +300,11 @@ describe(DatabaseService.name, () => { database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', @@ -328,8 +333,11 @@ describe(DatabaseService.name, () => { database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index ab8731ea4d..2b195ae8c9 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,5 +1,3 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; -import postgres from 'postgres'; import { ImmichEnvironment, ImmichWorker } from 'src/enum'; import { DatabaseExtension } from 'src/interfaces/database.interface'; import { EnvData } from 'src/repositories/config.repository'; @@ -24,12 +22,7 @@ const envData: EnvData = { database: { config: { - kysely: { - dialect: new PostgresJSDialect({ - postgres: postgres({ database: 'immich', host: 'database', port: 5432 }), - }), - log: ['error'], - }, + kysely: { database: 'immich', host: 'database', port: 5432 }, typeorm: { connectionType: 'parts', database: 'immich',