Files
immich/server/src/maintenance/maintenance-worker.service.ts
T
2026-03-17 17:54:39 -04:00

359 lines
11 KiB
TypeScript

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { parse } from 'cookie';
import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http';
import { serverVersion } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import {
MaintenanceAuthDto,
MaintenanceDetectInstallResponseDto,
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
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 { DatabaseBackupService } from 'src/services/database-backup.service';
import { type ServerService as _ServerService } from 'src/services/server.service';
import { type VersionService as _VersionService } from 'src/services/version.service';
import { MaintenanceModeState } from 'src/types';
import { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
/**
* This service is available inside of maintenance mode to manage maintenance mode
*/
@Injectable()
export class MaintenanceWorkerService {
#secret: string | null = null;
#status: MaintenanceStatusResponseDto = {
active: true,
action: MaintenanceAction.Start,
};
constructor(
protected logger: LoggingRepository,
private appRepository: AppRepository,
private configRepository: ConfigRepository,
private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWebsocketRepository: MaintenanceWebsocketRepository,
private maintenanceHealthRepository: MaintenanceHealthRepository,
private storageRepository: StorageRepository,
private processRepository: ProcessRepository,
private databaseRepository: DatabaseRepository,
private databaseBackupService: DatabaseBackupService,
) {
this.logger.setContext(this.constructor.name);
}
mock(status: MaintenanceStatusResponseDto) {
this.#secret = 'secret';
this.#status = status;
}
async init() {
const state = (await this.systemMetadataRepository.get(
SystemMetadataKey.MaintenanceMode,
)) as MaintenanceModeState & { isMaintenanceMode: true };
this.#secret = state.secret;
this.#status = {
active: true,
action: state.action?.action ?? MaintenanceAction.Start,
};
StorageCore.setMediaLocation(this.detectMediaLocation());
this.maintenanceWebsocketRepository.setAuthFn(async (client) => this.authenticate(client.request.headers));
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
await this.logSecret();
if (state.action) {
void this.runAction(state.action);
}
}
/**
* {@link _BaseService.configRepos}
*/
private get configRepos() {
return {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
};
}
/**
* {@link _BaseService.prototype.getConfig}
*/
private getConfig(options: { withCache: boolean }) {
return getConfig(this.configRepos, options);
}
/**
* {@link _ServerService.getSystemConfig}
*/
getSystemConfig() {
return {
maintenanceMode: true,
} as ServerConfigDto;
}
/**
* {@link _VersionService.getVersion}
*/
getVersion() {
return ServerVersionResponseDto.fromSemVer(serverVersion);
}
ping(): ServerPingResponse {
return { res: 'pong' };
}
/**
* {@link _ApiService.ssr}
*/
ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv();
let index = '';
try {
index = readFileSync(resourcePaths.web.indexHtml).toString();
} catch {
this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
}
return (request: Request, res: Response, next: NextFunction) => {
if (
request.url.startsWith('/api') ||
request.method.toLowerCase() !== 'get' ||
excludePaths.some((item) => request.url.startsWith(item))
) {
return next();
}
const maintenancePath = '/maintenance';
if (!request.url.startsWith(maintenancePath)) {
const params = new URLSearchParams();
params.set('continue', request.path);
return res.redirect(`${maintenancePath}?${params}`);
}
res.status(200).type('text/html').header('Cache-Control', 'no-store').send(index);
};
}
/**
* {@link _StorageService.detectMediaLocation}
*/
detectMediaLocation(): string {
const envData = this.configRepository.getEnv();
if (envData.storage.mediaLocation) {
return envData.storage.mediaLocation;
}
const targets: string[] = [];
const candidates = ['/data', '/usr/src/app/upload'];
for (const candidate of candidates) {
const exists = this.storageRepository.existsSync(candidate);
if (exists) {
targets.push(candidate);
}
}
if (targets.length === 1) {
return targets[0];
}
return '/usr/src/app/upload';
}
private get secret() {
if (!this.#secret) {
throw new Error('Secret is not initialised yet.');
}
return this.#secret;
}
private get backupRepos() {
return {
logger: this.logger,
storage: this.storageRepository,
config: this.configRepository,
process: this.processRepository,
database: this.databaseRepository,
health: this.maintenanceHealthRepository,
};
}
private getStatus(): MaintenanceStatusResponseDto {
return this.#status;
}
private getPublicStatus(): MaintenanceStatusResponseDto {
const state = structuredClone(this.#status);
if (state.error) {
state.error = 'Something went wrong, see logs!';
}
return state;
}
setStatus(status: MaintenanceStatusResponseDto): void {
this.#status = status;
this.maintenanceWebsocketRepository.serverSend('MaintenanceStatus', status);
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'private', status);
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'public', this.getPublicStatus());
}
async logSecret(): Promise<void> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
const url = await createMaintenanceLoginUrl(
baseUrl,
{
username: 'immich-admin',
},
this.secret,
);
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
}
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken];
return this.login(jwtToken);
}
async status(potentiallyJwt?: string): Promise<MaintenanceStatusResponseDto> {
try {
await this.login(potentiallyJwt);
return this.getStatus();
} catch {
return this.getPublicStatus();
}
}
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
return detectPriorInstall(this.storageRepository);
}
async login(jwt?: string): Promise<MaintenanceAuthDto> {
if (!jwt) {
throw new UnauthorizedException('Missing JWT Token');
}
try {
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(this.secret));
return result.payload;
} catch {
throw new UnauthorizedException('Invalid JWT Token');
}
}
async setAction(action: SetMaintenanceModeDto) {
this.setStatus({
active: true,
action: action.action,
});
await this.runAction(action);
}
async runAction(action: SetMaintenanceModeDto) {
switch (action.action) {
case MaintenanceAction.Start: {
return;
}
case MaintenanceAction.End: {
return this.endMaintenance();
}
case MaintenanceAction.SelectDatabaseRestore: {
return;
}
}
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!lock) {
return;
}
this.logger.log(`Running maintenance action ${action.action}`);
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: this.secret,
action: {
action: MaintenanceAction.Start,
},
});
try {
if (!action.restoreBackupFilename) {
throw new Error("Expected restoreBackupFilename but it's missing!");
}
await this.restoreBackup(action.restoreBackupFilename);
} catch (error) {
this.logger.error(`Encountered error running action: ${error}`);
this.setStatus({
active: true,
action: action.action,
task: 'error',
error: '' + error,
});
}
}
private async restoreBackup(filename: string): Promise<void> {
this.setStatus({
active: true,
action: MaintenanceAction.RestoreDatabase,
task: 'ready',
progress: 0,
});
await this.databaseBackupService.restoreDatabaseBackup(filename, (task, progress) =>
this.setStatus({
active: true,
action: MaintenanceAction.RestoreDatabase,
progress,
task,
}),
);
await this.setAction({
action: MaintenanceAction.End,
});
}
private async endMaintenance(): Promise<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
// => corresponds to notification.service.ts#onAppRestart
this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
}