feat: add MaintenanceEphemeralStateRepository

refactor: cache the secret in memory
This commit is contained in:
izzy
2025-11-19 15:41:27 +00:00
parent 56c93a71c0
commit 7c2e8b1d62
4 changed files with 48 additions and 29 deletions

View File

@@ -9,8 +9,9 @@ import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants'; import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers'; import { controllers } from 'src/controllers';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker, SystemMetadataKey } from 'src/enum';
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard'; import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller'; import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
@@ -34,6 +35,7 @@ import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
import { QueueService } from 'src/services/queue.service'; import { QueueService } from 'src/services/queue.service';
import { MaintenanceModeState } from 'src/types';
import { getKyselyConfig } from 'src/utils/database'; import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter]; const common = [...repositories, ...services, GlobalExceptionFilter];
@@ -113,6 +115,7 @@ export class ApiModule extends BaseModule {}
SystemMetadataRepository, SystemMetadataRepository,
AppRepository, AppRepository,
MaintenanceWebsocketRepository, MaintenanceWebsocketRepository,
MaintenanceEphemeralStateRepository,
MaintenanceWorkerService, MaintenanceWorkerService,
...commonMiddleware, ...commonMiddleware,
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard }, { provide: APP_GUARD, useClass: MaintenanceAuthGuard },
@@ -123,13 +126,20 @@ export class MaintenanceModule {
constructor( constructor(
@Inject(IWorker) private worker: ImmichWorker, @Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository, logger: LoggingRepository,
private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWorkerService: MaintenanceWorkerService, private maintenanceWorkerService: MaintenanceWorkerService,
private maintenanceWebsocketRepository: MaintenanceWebsocketRepository, private maintenanceWebsocketRepository: MaintenanceWebsocketRepository,
private maintenanceEphemeralStateRepository: MaintenanceEphemeralStateRepository,
) { ) {
logger.setAppName(this.worker); logger.setAppName(this.worker);
} }
async onModuleInit() { async onModuleInit() {
const state = (await this.systemMetadataRepository.get(
SystemMetadataKey.MaintenanceMode,
)) as MaintenanceModeState & { isMaintenanceMode: true };
this.maintenanceEphemeralStateRepository.setSecret(state.secret);
StorageCore.setMediaLocation(this.maintenanceWorkerService.detectMediaLocation()); StorageCore.setMediaLocation(this.maintenanceWorkerService.detectMediaLocation());
this.maintenanceWebsocketRepository.setAuthFn(async (client) => this.maintenanceWebsocketRepository.setAuthFn(async (client) =>

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class MaintenanceEphemeralStateRepository {
#secret: string = null!;
setSecret(secret: string) {
this.#secret = secret;
}
getSecret() {
return this.#secret;
}
}

View File

@@ -1,6 +1,7 @@
import { UnauthorizedException } from '@nestjs/common'; import { UnauthorizedException } from '@nestjs/common';
import { SignJWT } from 'jose'; import { SignJWT } from 'jose';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { automock, getMocks, ServiceMocks } from 'test/utils'; import { automock, getMocks, ServiceMocks } from 'test/utils';
@@ -8,17 +9,28 @@ import { automock, getMocks, ServiceMocks } from 'test/utils';
describe(MaintenanceWorkerService.name, () => { describe(MaintenanceWorkerService.name, () => {
let sut: MaintenanceWorkerService; let sut: MaintenanceWorkerService;
let mocks: ServiceMocks; let mocks: ServiceMocks;
let maintenanceWorkerRepositoryMock: MaintenanceWebsocketRepository; let maintenanceWebsocketRepositoryMock: MaintenanceWebsocketRepository;
let maintenanceEphemeralStateRepositoryMock: MaintenanceEphemeralStateRepository;
beforeEach(() => { beforeEach(() => {
mocks = getMocks(); mocks = getMocks();
maintenanceWorkerRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false }); maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, {
args: [mocks.logger],
strict: false,
});
maintenanceEphemeralStateRepositoryMock = automock(MaintenanceEphemeralStateRepository, {
args: [mocks.logger],
strict: false,
});
sut = new MaintenanceWorkerService( sut = new MaintenanceWorkerService(
mocks.logger as never, mocks.logger as never,
mocks.app, mocks.app,
mocks.config, mocks.config,
mocks.systemMetadata as never, mocks.systemMetadata as never,
maintenanceWorkerRepositoryMock, maintenanceWebsocketRepositoryMock,
maintenanceEphemeralStateRepositoryMock,
mocks.storage as never, mocks.storage as never,
mocks.process, mocks.process,
mocks.database as never, mocks.database as never,
@@ -144,11 +156,11 @@ describe(MaintenanceWorkerService.name, () => {
isMaintenanceMode: false, isMaintenanceMode: false,
}); });
expect(maintenanceWorkerRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', { expect(maintenanceWebsocketRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
isMaintenanceMode: false, isMaintenanceMode: false,
}); });
expect(maintenanceWorkerRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', { expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
isMaintenanceMode: false, isMaintenanceMode: false,
}); });
}); });

View File

@@ -5,7 +5,9 @@ import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { ImmichCookie, SystemMetadataKey } from 'src/enum'; import { ImmichCookie, SystemMetadataKey } from 'src/enum';
import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { AppRepository } from 'src/repositories/app.repository'; import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
@@ -34,6 +36,7 @@ export class MaintenanceWorkerService {
private configRepository: ConfigRepository, private configRepository: ConfigRepository,
private systemMetadataRepository: SystemMetadataRepository, private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWorkerRepository: MaintenanceWebsocketRepository, private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
private maintenanceEphemeralStateRepository: MaintenanceEphemeralStateRepository,
private storageRepository: StorageRepository, private storageRepository: StorageRepository,
private processRepository: ProcessRepository, private processRepository: ProcessRepository,
private databaseRepository: DatabaseRepository, private databaseRepository: DatabaseRepository,
@@ -63,21 +66,9 @@ export class MaintenanceWorkerService {
* {@link _ServerService.getSystemConfig} * {@link _ServerService.getSystemConfig}
*/ */
async getSystemConfig() { async getSystemConfig() {
const config = await this.getConfig({ withCache: false });
return { return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText,
isInitialized: true,
isOnboarded: true,
externalDomain: config.server.externalDomain,
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: true, maintenanceMode: true,
}; } as ServerConfigDto;
} }
/** /**
@@ -139,14 +130,6 @@ export class MaintenanceWorkerService {
return '/usr/src/app/upload'; return '/usr/src/app/upload';
} }
private async secret(): Promise<string> {
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as {
secret: string;
};
return state.secret;
}
async logSecret(): Promise<void> { async logSecret(): Promise<void> {
const { server } = await this.getConfig({ withCache: true }); const { server } = await this.getConfig({ withCache: true });
@@ -156,7 +139,7 @@ export class MaintenanceWorkerService {
{ {
username: 'immich-admin', username: 'immich-admin',
}, },
await this.secret(), this.maintenanceEphemeralStateRepository.getSecret(),
); );
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`); this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
@@ -172,7 +155,7 @@ export class MaintenanceWorkerService {
throw new UnauthorizedException('Missing JWT Token'); throw new UnauthorizedException('Missing JWT Token');
} }
const secret = await this.secret(); const secret = this.maintenanceEphemeralStateRepository.getSecret();
try { try {
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(secret)); const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(secret));