From 42ac9cf9222a1bfbcae1cc99cfab350ce2301c2f Mon Sep 17 00:00:00 2001 From: izzy Date: Fri, 31 Oct 2025 14:47:49 +0000 Subject: [PATCH] feat: boot a stripped down maintenance api if enabled --- open-api/immich-openapi-specs.json | 4 ++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/app.module.ts | 9 ++- server/src/controllers/index.ts | 4 +- server/src/controllers/server.controller.ts | 33 +++++++++ server/src/dtos/server.dto.ts | 1 + server/src/enum.ts | 1 + server/src/main.ts | 46 ++++++++++-- server/src/services/server.service.ts | 1 + server/src/workers/maintenance.ts | 77 +++++++++++++++++++++ 10 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 server/src/workers/maintenance.ts diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 444f3e71eb..e30b996e4b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14325,6 +14325,9 @@ "loginPageMessage": { "type": "string" }, + "maintenanceMode": { + "type": "boolean" + }, "mapDarkStyleUrl": { "type": "string" }, @@ -14349,6 +14352,7 @@ "isInitialized", "isOnboarded", "loginPageMessage", + "maintenanceMode", "mapDarkStyleUrl", "mapLightStyleUrl", "oauthButtonText", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 27a71bffd3..7b8c24f7ec 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1159,6 +1159,7 @@ export type ServerConfigDto = { isInitialized: boolean; isOnboarded: boolean; loginPageMessage: string; + maintenanceMode: boolean; mapDarkStyleUrl: string; mapLightStyleUrl: string; oauthButtonText: string; diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 8079441329..7ae60ffeb4 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -7,7 +7,7 @@ import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { commandsAndQuestions } from 'src/commands'; import { IWorker } from 'src/constants'; -import { controllers } from 'src/controllers'; +import { controllers, maintenanceControllers } from 'src/controllers'; import { ImmichWorker } from 'src/enum'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; @@ -91,6 +91,13 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { }) export class ApiModule extends BaseModule {} +@Module({ + imports: [...imports, ScheduleModule.forRoot()], + controllers: [...maintenanceControllers], + providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Maintenance }], +}) +export class MaintenanceModule extends BaseModule {} + @Module({ imports: [...imports], providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry], diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e3661ec794..df79319bd0 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -19,7 +19,7 @@ import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; import { SearchController } from 'src/controllers/search.controller'; -import { ServerController } from 'src/controllers/server.controller'; +import { MaintenanceServerController, ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { StackController } from 'src/controllers/stack.controller'; @@ -69,3 +69,5 @@ export const controllers = [ UserController, ViewController, ]; + +export const maintenanceControllers = [MaintenanceServerController]; diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index f9a340eb31..571df39b9f 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -115,3 +115,36 @@ export class ServerController { return this.systemMetadataService.getVersionCheckState(); } } + +@Controller('server') +export class MaintenanceServerController { + constructor( + private service: ServerService, + private versionService: VersionService, + ) {} + + @Get('ping') + pingServer(): ServerPingResponse { + return this.service.ping(); + } + + @Get('version') + getServerVersion(): ServerVersionResponseDto { + return this.versionService.getVersion(); + } + + @Get('features') + getServerFeatures(): Promise { + return this.service.getFeatures(); + } + + @Get('config') + getServerConfig(): Promise { + return this.service.getSystemConfig(); + } + + @Get('media-types') + getSupportedMediaTypes(): ServerMediaTypesResponseDto { + return this.service.getSupportedMediaTypes(); + } +} diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index d7589c8a29..e98cb2edf6 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -154,6 +154,7 @@ export class ServerConfigDto { publicUsers!: boolean; mapDarkStyleUrl!: string; mapLightStyleUrl!: string; + maintenanceMode!: boolean; } export class ServerFeaturesDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index 0755f75f70..701a34a711 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -457,6 +457,7 @@ export enum ImmichEnvironment { export enum ImmichWorker { Api = 'api', + Maintenance = 'maintenance', Microservices = 'microservices', } diff --git a/server/src/main.ts b/server/src/main.ts index df0845956a..19a7161939 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,10 +1,14 @@ +import { NestFactory } from '@nestjs/core'; import { CommandFactory } from 'nest-commander'; import { ChildProcess, fork } from 'node:child_process'; import { dirname, join } from 'node:path'; import { Worker } from 'node:worker_threads'; -import { ImmichAdminModule } from 'src/app.module'; +import { ApiModule, ImmichAdminModule } from 'src/app.module'; import { ImmichWorker, LogLevel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { getConfig } from 'src/utils/config'; /** * Manages worker lifecycle @@ -22,17 +26,45 @@ class Workers { /** * Boot all enabled workers */ - bootstrap() { + async bootstrap() { + const { + maintenance: { enabled: maintenanceMode }, + } = await this.getConfig(); const { workers } = new ConfigRepository().getEnv(); - // todo: filter for API if in maintenance - // todo: swap API for maintenance API - - for (const worker of workers) { - this.startWorker(worker); + if (maintenanceMode) { + this.startWorker(ImmichWorker.Maintenance); + } else { + for (const worker of workers) { + this.startWorker(worker); + } } } + /** + * Initialise a short-lived Nest application to build configuration + * @returns System configuration + */ + private async getConfig() { + const app = await NestFactory.create(ApiModule); + const logger = await app.resolve(LoggingRepository); + const configRepo = app.get(ConfigRepository); + const metadataRepo = app.get(SystemMetadataRepository); + + const systemConfig = await getConfig( + { + configRepo, + metadataRepo, + logger, + }, + { withCache: false }, + ); + + await app.close(); + + return systemConfig; + } + /** * Start an individual worker * @param name Worker diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 5c3669dcbb..fa2809e9e0 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -130,6 +130,7 @@ export class ServerService extends BaseService { publicUsers: config.server.publicUsers, mapDarkStyleUrl: config.map.darkStyle, mapLightStyleUrl: config.map.lightStyle, + maintenanceMode: config.maintenance.enabled, }; } diff --git a/server/src/workers/maintenance.ts b/server/src/workers/maintenance.ts new file mode 100644 index 0000000000..86d1b44da2 --- /dev/null +++ b/server/src/workers/maintenance.ts @@ -0,0 +1,77 @@ +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { json } from 'body-parser'; +import compression from 'compression'; +import cookieParser from 'cookie-parser'; +import { existsSync } from 'node:fs'; +import sirv from 'sirv'; +import { MaintenanceModule } from 'src/app.module'; +import { excludePaths, serverVersion } from 'src/constants'; +import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; +import { ApiService } from 'src/services/api.service'; +import { SystemConfigService } from 'src/services/system-config.service'; +import { isStartUpError, useSwagger } from 'src/utils/misc'; +async function bootstrap() { + process.title = 'immich-api'; + + const { telemetry, network } = new ConfigRepository().getEnv(); + if (telemetry.metrics.size > 0) { + bootstrapTelemetry(telemetry.apiPort); + } + + const app = await NestFactory.create(MaintenanceModule, { bufferLogs: true }); + const logger = await app.resolve(LoggingRepository); + const configRepository = app.get(ConfigRepository); + app.get(SystemConfigService).nestApplication = app; + + const { environment, host, port, resourcePaths } = configRepository.getEnv(); + + logger.setContext('Bootstrap'); + app.useLogger(logger); + app.set('trust proxy', ['loopback', ...network.trustedProxies]); + app.set('etag', 'strong'); + app.use(cookieParser()); + app.use(json({ limit: '10mb' })); + if (configRepository.isDev()) { + app.enableCors(); + } + app.useWebSocketAdapter(new WebSocketAdapter(app)); + useSwagger(app, { write: configRepository.isDev() }); + + app.setGlobalPrefix('api', { exclude: excludePaths }); + if (existsSync(resourcePaths.web.root)) { + // copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46 + // provides serving of precompressed assets and caching of immutable assets + app.use( + sirv(resourcePaths.web.root, { + etag: true, + gzip: true, + brotli: true, + extensions: [], + setHeaders: (res, pathname) => { + if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) { + res.setHeader('cache-control', 'public,max-age=31536000,immutable'); + } + }, + }), + ); + } + app.use(app.get(ApiService).ssr(excludePaths)); + app.use(compression()); + + const server = await (host ? app.listen(port, host) : app.listen(port)); + server.requestTimeout = 24 * 60 * 60 * 1000; + + logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `); +} + +bootstrap().catch((error) => { + if (!isStartUpError(error)) { + console.error(error); + } + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +});