refactor: use respondWithCookie

refactor: merge start/end into one route
refactor: rename MaintenanceRepository to AppRepository
chore: use new ApiTag/Endpoint
refactor: apply other requested changes
This commit is contained in:
izzy 2025-11-14 14:54:06 +00:00
parent 15c0d5f27a
commit 1fc98990ab
No known key found for this signature in database
GPG key ID: 5059F398521BB0F6
22 changed files with 1041 additions and 10008 deletions

View file

@ -17,11 +17,11 @@ import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { MaintenanceAuthGuard } from 'src/middleware/maintenance-auth.guard';
import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MaintenanceWorkerRepository } from 'src/repositories/maintenance-worker.repository';
import { MaintenanceRepository } from 'src/repositories/maintenance.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
@ -104,7 +104,7 @@ export class ApiModule extends BaseModule {}
ConfigRepository,
LoggingRepository,
SystemMetadataRepository,
MaintenanceRepository,
AppRepository,
MaintenanceWorkerRepository,
MaintenanceWorkerService,
...commonMiddleware,

View file

@ -150,6 +150,7 @@ export const endpointTags: Record<ApiTag, string> = {
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Libraries]:
'An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries.',
[ApiTag.Maintenance]: 'Maintenance mode allows you to put Immich in a read-only state to perform various operations.',
[ApiTag.Map]:
'Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data.',
[ApiTag.Memories]:

View file

@ -1,13 +1,12 @@
import { BadRequestException, Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { MaintenanceAuthDto, MaintenanceLoginDto } from 'src/dtos/maintenance.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/middleware/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/services/maintenance-worker.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags('Maintenance (admin)')
@Controller()
export class MaintenanceWorkerController {
constructor(private service: MaintenanceWorkerService) {}
@ -21,23 +20,21 @@ export class MaintenanceWorkerController {
async maintenanceLogin(
@Req() request: Request,
@Body() dto: MaintenanceLoginDto,
@Res({ passthrough: true }) response: Response,
@Res({ passthrough: true }) res: Response,
): Promise<MaintenanceAuthDto> {
const token = dto.token ?? request.cookies[ImmichCookie.MaintenanceToken];
const auth = await this.service.login(token);
response.cookie(ImmichCookie.MaintenanceToken, token);
return auth;
return respondWithCookie(res, auth, {
isSecure: true,
values: [{ key: ImmichCookie.MaintenanceToken, value: token }],
});
}
@Post('admin/maintenance/start')
@Post('admin/maintenance')
@MaintenanceRoute()
startMaintenance(): void {
throw new BadRequestException('Already in maintenance mode');
}
@Post('admin/maintenance/end')
@MaintenanceRoute()
async endMaintenance(): Promise<void> {
await this.service.endMaintenance();
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
if (!dto.maintenanceMode) {
await this.service.endMaintenance();
}
}
}

View file

@ -1,32 +1,47 @@
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto } from 'src/dtos/maintenance.dto';
import { ImmichCookie, Permission } from 'src/enum';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MaintenanceService } from 'src/services/maintenance.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags('Maintenance (admin)')
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(private service: MaintenanceService) {}
@Post('login')
@Endpoint({
summary: 'Log into maintenance mode',
description: 'Login with maintenance token or cookie to receive current information and perform further actions.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
maintenanceLogin(@Body() _dto: MaintenanceLoginDto): MaintenanceAuthDto {
throw new BadRequestException('Not in maintenance mode');
}
@Post('start')
@Post()
@Endpoint({
summary: 'Set maintenance mode',
description: 'Put Immich into or take it out of maintenance mode',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async startMaintenance(@Auth() auth: AuthDto, @Res({ passthrough: true }) response: Response): Promise<void> {
const { jwt } = await this.service.startMaintenance(auth.user.name);
response.cookie(ImmichCookie.MaintenanceToken, jwt);
}
@Post('end')
@Authenticated({ permission: Permission.Maintenance, admin: true })
endMaintenance(): void {
throw new BadRequestException('Not in maintenance mode');
async setMaintenanceMode(
@Auth() auth: AuthDto,
@Body() dto: SetMaintenanceModeDto,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
if (dto.maintenanceMode) {
const { jwt } = await this.service.startMaintenance(auth.user.name);
return respondWithCookie(res, undefined, {
isSecure: true,
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
});
}
}
}

View file

@ -1,4 +1,9 @@
import { ValidateString } from 'src/validation';
import { ValidateBoolean, ValidateString } from 'src/validation';
export class SetMaintenanceModeDto {
@ValidateBoolean()
maintenanceMode!: boolean;
}
export class MaintenanceLoginDto {
@ValidateString({ optional: true })

View file

@ -796,6 +796,7 @@ export enum ApiTag {
Faces = 'Faces',
Jobs = 'Jobs',
Libraries = 'Libraries',
Maintenance = 'Maintenance (admin)',
Map = 'Map',
Memories = 'Memories',
Notifications = 'Notifications',

View file

@ -18,17 +18,13 @@ class Workers {
/**
* Currently running workers
*/
workers: Partial<Record<ImmichWorker, Worker | ChildProcess>>;
workers: Partial<Record<ImmichWorker, { kill: () => void }>> = {};
/**
* Fail-safe in case anything dies during restart
*/
restarting = false;
constructor() {
this.workers = {};
}
/**
* Boot all enabled workers
*/
@ -80,17 +76,27 @@ class Workers {
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
const worker: Worker | ChildProcess =
name === ImmichWorker.Api
? fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
})
: new Worker(workerFile);
let anyWorker: Worker | ChildProcess;
let kill: () => void;
worker.on('error', (error) => this.onError(name, error));
worker.on('exit', (exitCode) => this.onExit(name, exitCode));
if (name === ImmichWorker.Api) {
const worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
this.workers[name] = worker;
kill = () => worker.kill();
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
kill = () => worker.terminate();
anyWorker = worker;
}
anyWorker.on('error', (error) => this.onError(name, error));
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
this.workers[name] = { kill };
}
onError(name: ImmichWorker, error: Error) {

View file

@ -51,7 +51,7 @@ export class MaintenanceAuthGuard implements CanActivate {
}
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
request['auth'] = await this.service.authenticate(request.headers);
request.auth = await this.service.authenticate(request.headers);
return true;
}

View file

@ -2,11 +2,9 @@ import { Injectable } from '@nestjs/common';
import { ExitCode } from 'src/enum';
@Injectable()
export class MaintenanceRepository {
export class AppRepository {
private closeFn?: () => Promise<void>;
constructor() {}
exitApp() {
/* eslint-disable unicorn/no-process-exit */
void this.closeFn?.().finally(() => process.exit(ExitCode.AppRestart));

View file

@ -3,6 +3,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@ -18,7 +19,6 @@ import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MaintenanceRepository } from 'src/repositories/maintenance.repository';
import { MapRepository } from 'src/repositories/map.repository';
import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
@ -55,6 +55,7 @@ export const repositories = [
AlbumUserRepository,
AuditRepository,
ApiKeyRepository,
AppRepository,
AssetRepository,
AssetJobRepository,
ConfigRepository,
@ -69,7 +70,6 @@ export const repositories = [
LibraryRepository,
LoggingRepository,
MachineLearningRepository,
MaintenanceRepository,
MapRepository,
MediaRepository,
MemoryRepository,

View file

@ -7,9 +7,9 @@ import {
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { AppRepository } from 'src/repositories/app.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MaintenanceRepository } from 'src/repositories/maintenance.repository';
export const serverEvents = ['AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
@ -30,7 +30,7 @@ export class MaintenanceWorkerRepository implements OnGatewayConnection, OnGatew
constructor(
private logger: LoggingRepository,
private maintenanceRepository: MaintenanceRepository,
private maintenanceRepository: AppRepository,
) {
this.logger.setContext(MaintenanceWorkerRepository.name);
}

View file

@ -10,6 +10,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@ -25,7 +26,6 @@ import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MaintenanceRepository } from 'src/repositories/maintenance.repository';
import { MapRepository } from 'src/repositories/map.repository';
import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
@ -65,6 +65,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
AlbumRepository,
AlbumUserRepository,
ApiKeyRepository,
AppRepository,
AssetRepository,
AssetJobRepository,
AuditRepository,
@ -79,7 +80,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
JobRepository,
LibraryRepository,
MachineLearningRepository,
MaintenanceRepository,
MapRepository,
MediaRepository,
MemoryRepository,
@ -120,6 +120,7 @@ export class BaseService {
protected albumRepository: AlbumRepository,
protected albumUserRepository: AlbumUserRepository,
protected apiKeyRepository: ApiKeyRepository,
protected appRepository: AppRepository,
protected assetRepository: AssetRepository,
protected assetJobRepository: AssetJobRepository,
protected auditRepository: AuditRepository,
@ -134,7 +135,6 @@ export class BaseService {
protected jobRepository: JobRepository,
protected libraryRepository: LibraryRepository,
protected machineLearningRepository: MachineLearningRepository,
protected maintenanceRepository: MaintenanceRepository,
protected mapRepository: MapRepository,
protected mediaRepository: MediaRepository,
protected memoryRepository: MemoryRepository,

View file

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum';
@ -19,11 +19,6 @@ export class MaintenanceService extends BaseService {
}
async startMaintenance(username: string): Promise<{ jwt: string }> {
const { isMaintenanceMode } = await this.getMaintenanceMode();
if (isMaintenanceMode) {
throw new BadRequestException('Already in maintenance mode');
}
const secret = generateMaintenanceSecret();
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret });
await this.eventRepository.emit('AppRestart', { isMaintenanceMode: true });
@ -37,7 +32,7 @@ export class MaintenanceService extends BaseService {
@OnEvent({ name: 'AppRestart', server: true })
onRestart(): void {
this.maintenanceRepository.exitApp();
this.appRepository.exitApp();
}
async createLoginUrl(auth: MaintenanceAuthDto, secret?: string): Promise<string> {

View file

@ -2,7 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { ApiModule } from 'src/app.module';
import { MaintenanceRepository } from 'src/repositories/maintenance.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ApiService } from 'src/services/api.service';
import { isStartUpError } from 'src/utils/misc';
@ -12,7 +12,7 @@ async function bootstrap() {
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
app.get(MaintenanceRepository).setCloseFn(() => app.close());
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {
ssr: ApiService,

View file

@ -2,7 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { MaintenanceModule } from 'src/app.module';
import { MaintenanceRepository } from 'src/repositories/maintenance.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { MaintenanceWorkerService } from 'src/services/maintenance-worker.service';
import { isStartUpError } from 'src/utils/misc';
@ -11,7 +11,7 @@ async function bootstrap() {
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
app.get(MaintenanceRepository).setCloseFn(() => app.close());
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {
permitSwaggerWrite: false,
ssr: MaintenanceWorkerService,

View file

@ -3,9 +3,9 @@ import { isMainThread } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
import { serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MaintenanceRepository } from 'src/repositories/maintenance.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { isStartUpError } from 'src/utils/misc';
@ -18,7 +18,7 @@ export async function bootstrap() {
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
app.get(MaintenanceRepository).setCloseFn(() => app.close());
app.get(AppRepository).setCloseFn(() => app.close());
const { environment, host } = configRepository.getEnv();