From 36baeb603fffaee856ba9166dbe2d10ff296de68 Mon Sep 17 00:00:00 2001 From: izzy Date: Mon, 10 Nov 2025 11:32:49 +0000 Subject: [PATCH] refactor: move static methods into util --- .../src/controllers/maintenance.controller.ts | 6 +- .../repositories/maintenance.repository.ts | 44 +----------- server/src/services/cli.service.spec.ts | 8 --- server/src/services/cli.service.ts | 12 ++-- .../services/maintenance-worker.service.ts | 4 +- server/src/services/maintenance.service.ts | 45 ++++-------- server/src/utils/maintenance.ts | 68 +++++++++++++++++++ 7 files changed, 92 insertions(+), 95 deletions(-) create mode 100644 server/src/utils/maintenance.ts diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index f3daadc31d..10df25a852 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -20,11 +20,7 @@ export class MaintenanceController { @Post('start') @Authenticated({ permission: Permission.Maintenance, admin: true }) async startMaintenance(@Auth() auth: AuthDto, @Res({ passthrough: true }) response: Response): Promise { - const { secret } = await this.service.startMaintenance(); - const jwt = await this.service.createJwt(secret, { - username: auth.user.name, - }); - + const { jwt } = await this.service.startMaintenance(auth); response.cookie(ImmichCookie.MaintenanceToken, jwt); } diff --git a/server/src/repositories/maintenance.repository.ts b/server/src/repositories/maintenance.repository.ts index 9bc85eb7a4..74c7883682 100644 --- a/server/src/repositories/maintenance.repository.ts +++ b/server/src/repositories/maintenance.repository.ts @@ -1,16 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { createAdapter } from '@socket.io/redis-adapter'; -import Redis from 'ioredis'; -import { Server as SocketIO } from 'socket.io'; import { ExitCode } from 'src/enum'; -import { ConfigRepository } from 'src/repositories/config.repository'; -import { AppRestartEvent } from 'src/repositories/event.repository'; @Injectable() export class MaintenanceRepository { private closeFn?: () => Promise; - constructor(private configRepository: ConfigRepository) {} + constructor() {} exitApp() { /* eslint-disable unicorn/no-process-exit */ @@ -24,41 +19,4 @@ export class MaintenanceRepository { setCloseFn(fn: () => Promise) { this.closeFn = fn; } - - sendOneShotAppRestart(state: AppRestartEvent): void { - const server = new SocketIO(); - const pubClient = new Redis(this.configRepository.getEnv().redis); - const subClient = pubClient.duplicate(); - server.adapter(createAdapter(pubClient, subClient)); - - /** - * Keep trying until we manage to stop Immich - * - * Sometimes there appear to be communication - * issues between to the other servers. - * - * This issue only occurs with this method. - */ - function tryTerminate(cb: () => void) { - server.serverSideEmit('AppRestart', state, (arg0: null | any, responses: any[]) => { - if (arg0 === null && responses.length === 0) { - console.info( - "\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.", - ); - - setTimeout(() => tryTerminate(cb), 1e3); - } else { - cb(); - } - }); - } - - // => corresponds to notification.service.ts#onAppRestart - server.emit('AppRestartV1', state, () => { - tryTerminate(() => { - pubClient.disconnect(); - subClient.disconnect(); - }); - }); - } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 8cafbc1f08..49fa5cf5b8 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -102,10 +102,6 @@ describe(CliService.name, () => { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: false, }); - - expect(mocks.maintenance.sendOneShotAppRestart).toHaveBeenCalledWith({ - isMaintenanceMode: false, - }); }); }); @@ -134,10 +130,6 @@ describe(CliService.name, () => { isMaintenanceMode: true, secret: expect.stringMatching(/^\w{128}$/), }); - - expect(mocks.maintenance.sendOneShotAppRestart).toHaveBeenCalledWith({ - isMaintenanceMode: true, - }); }); const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/; diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index bbf661b9e1..3d248edc7a 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { MaintenanceService } from 'src/services/maintenance.service'; +import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; @Injectable() @@ -56,7 +56,7 @@ export class CliService extends BaseService { const state = { isMaintenanceMode: false as const }; await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); - this.maintenanceRepository.sendOneShotAppRestart(state); + sendOneShotAppRestart(state); return { alreadyDisabled: false, @@ -77,24 +77,24 @@ export class CliService extends BaseService { if (state.isMaintenanceMode) { return { - authUrl: await MaintenanceService.createLoginUrl(baseUrl, payload, state.secret), + authUrl: await createMaintenanceLoginUrl(baseUrl, payload, state.secret), alreadyEnabled: true, }; } - const secret = MaintenanceService.generateSecret(); + const secret = generateMaintenanceSecret(); await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret, }); - this.maintenanceRepository.sendOneShotAppRestart({ + sendOneShotAppRestart({ isMaintenanceMode: true, }); return { - authUrl: await MaintenanceService.createLoginUrl(baseUrl, payload, secret), + authUrl: await createMaintenanceLoginUrl(baseUrl, payload, secret), alreadyEnabled: false, }; } diff --git a/server/src/services/maintenance-worker.service.ts b/server/src/services/maintenance-worker.service.ts index 7df3a82274..3e7e32bbf1 100644 --- a/server/src/services/maintenance-worker.service.ts +++ b/server/src/services/maintenance-worker.service.ts @@ -12,10 +12,10 @@ import { MaintenanceWorkerRepository } from 'src/repositories/maintenance-worker import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { type ApiService as _ApiService } from 'src/services/api.service'; import { type BaseService as _BaseService } from 'src/services/base.service'; -import { MaintenanceService } from 'src/services/maintenance.service'; import { type ServerService as _ServerService } from 'src/services/server.service'; import { MaintenanceModeState } from 'src/types'; import { getConfig } from 'src/utils/config'; +import { createMaintenanceLoginUrl } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; /** @@ -116,7 +116,7 @@ export class MaintenanceWorkerService { const { server } = await this.getConfig({ withCache: true }); const baseUrl = getExternalDomain(server); - const url = await MaintenanceService.createLoginUrl( + const url = await createMaintenanceLoginUrl( baseUrl, { username: 'immich-admin', diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index 6fe646ce76..2167d88814 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -1,11 +1,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { SignJWT } from 'jose'; -import { randomBytes } from 'node:crypto'; import { OnEvent } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { MaintenanceModeState } from 'src/types'; +import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; /** @@ -23,17 +23,21 @@ export class MaintenanceService extends BaseService { throw new BadRequestException('Not in maintenance mode'); } - async startMaintenance(): Promise<{ secret: string }> { + async startMaintenance(auth: AuthDto): Promise<{ jwt: string }> { const { isMaintenanceMode } = await this.getMaintenanceMode(); if (isMaintenanceMode) { throw new BadRequestException('Already in maintenance mode'); } - const secret = MaintenanceService.generateSecret(); + const secret = generateMaintenanceSecret(); await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret }); await this.eventRepository.emit('AppRestart', { isMaintenanceMode: true }); - return { secret }; + return { + jwt: await signMaintenanceJwt(secret, { + username: auth.user.name, + }), + }; } endMaintenance(): void { @@ -49,36 +53,15 @@ export class MaintenanceService extends BaseService { const { server } = await this.getConfig({ withCache: true }); const baseUrl = getExternalDomain(server); - secret ??= await this.getMaintenanceMode().then((state) => { + if (!secret) { + const state = await this.getMaintenanceMode(); if (!state.isMaintenanceMode) { throw new Error('Not in maintenance mode'); } - return state.secret; - }); + secret = state.secret; + } - return await MaintenanceService.createLoginUrl(baseUrl, auth, secret!); - } - - createJwt(secret: string, data: MaintenanceAuthDto): Promise { - return MaintenanceService.createJwt(secret, data); - } - - static async createLoginUrl(baseUrl: string, auth: MaintenanceAuthDto, secret: string): Promise { - return `${baseUrl}/maintenance?token=${await MaintenanceService.createJwt(secret!, auth)}`; - } - - static async createJwt(secret: string, data: MaintenanceAuthDto): Promise { - const alg = 'HS256'; - - return await new SignJWT({ ...data }) - .setProtectedHeader({ alg }) - .setIssuedAt() - .setExpirationTime('4h') - .sign(new TextEncoder().encode(secret)); - } - - static generateSecret(): string { - return randomBytes(64).toString('hex'); + return await createMaintenanceLoginUrl(baseUrl, auth, secret!); } } diff --git a/server/src/utils/maintenance.ts b/server/src/utils/maintenance.ts new file mode 100644 index 0000000000..11daec0f53 --- /dev/null +++ b/server/src/utils/maintenance.ts @@ -0,0 +1,68 @@ +import { createAdapter } from '@socket.io/redis-adapter'; +import Redis from 'ioredis'; +import { SignJWT } from 'jose'; +import { randomBytes } from 'node:crypto'; +import { Server as SocketIO } from 'socket.io'; +import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { AppRestartEvent } from 'src/repositories/event.repository'; + +export function sendOneShotAppRestart(state: AppRestartEvent): void { + const server = new SocketIO(); + const { redis } = new ConfigRepository().getEnv(); + const pubClient = new Redis(redis); + const subClient = pubClient.duplicate(); + server.adapter(createAdapter(pubClient, subClient)); + + /** + * Keep trying until we manage to stop Immich + * + * Sometimes there appear to be communication + * issues between to the other servers. + * + * This issue only occurs with this method. + */ + function tryTerminate(cb: () => void) { + server.serverSideEmit('AppRestart', state, (arg0: null | any, responses: any[]) => { + if (arg0 === null && responses.length === 0) { + console.info( + "\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.", + ); + + setTimeout(() => tryTerminate(cb), 1e3); + } else { + cb(); + } + }); + } + + // => corresponds to notification.service.ts#onAppRestart + server.emit('AppRestartV1', state, () => { + tryTerminate(() => { + pubClient.disconnect(); + subClient.disconnect(); + }); + }); +} + +export async function createMaintenanceLoginUrl( + baseUrl: string, + auth: MaintenanceAuthDto, + secret: string, +): Promise { + return `${baseUrl}/maintenance?token=${await signMaintenanceJwt(secret!, auth)}`; +} + +export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDto): Promise { + const alg = 'HS256'; + + return await new SignJWT({ ...data }) + .setProtectedHeader({ alg }) + .setIssuedAt() + .setExpirationTime('4h') + .sign(new TextEncoder().encode(secret)); +} + +export function generateMaintenanceSecret(): string { + return randomBytes(64).toString('hex'); +}