refactor: move static methods into util

This commit is contained in:
izzy 2025-11-10 11:32:49 +00:00
parent db22797ae6
commit 36baeb603f
No known key found for this signature in database
GPG key ID: 5059F398521BB0F6
7 changed files with 92 additions and 95 deletions

View file

@ -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<void> {
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);
}

View file

@ -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<void>;
constructor(private configRepository: ConfigRepository) {}
constructor() {}
exitApp() {
/* eslint-disable unicorn/no-process-exit */
@ -24,41 +19,4 @@ export class MaintenanceRepository {
setCloseFn(fn: () => Promise<void>) {
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();
});
});
}
}

View file

@ -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-_]*)/;

View file

@ -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,
};
}

View file

@ -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',

View file

@ -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<string> {
return MaintenanceService.createJwt(secret, data);
}
static async createLoginUrl(baseUrl: string, auth: MaintenanceAuthDto, secret: string): Promise<string> {
return `${baseUrl}/maintenance?token=${await MaintenanceService.createJwt(secret!, auth)}`;
}
static async createJwt(secret: string, data: MaintenanceAuthDto): Promise<string> {
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!);
}
}

View file

@ -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<string> {
return `${baseUrl}/maintenance?token=${await signMaintenanceJwt(secret!, auth)}`;
}
export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDto): Promise<string> {
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');
}