diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c125b3bfb9..9f4958e761 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16604,6 +16604,9 @@ "type": "string" } }, + "required": [ + "action" + ], "type": "object" }, "ManualJobName": { diff --git a/server/src/app.module.ts b/server/src/app.module.ts index ae267c82b4..cd42bb92a0 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -146,6 +146,10 @@ export class MaintenanceModule { this.maintenanceWorkerService.authenticate(client.request.headers), ); + this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => + this.maintenanceEphemeralStateRepository.setStatus(status), + ); + await this.maintenanceWorkerService.logSecret(); } } diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index 9abd5341b2..ac9ecc664d 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -27,7 +27,9 @@ export class MaintenanceController { history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), }) maintenanceStatus(): MaintenanceStatusResponseDto { - return {}; + return { + action: MaintenanceAction.End, + }; } @Post('login') diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts index ba40166d45..d6e5e552e5 100644 --- a/server/src/dtos/maintenance.dto.ts +++ b/server/src/dtos/maintenance.dto.ts @@ -19,7 +19,7 @@ export class MaintenanceAuthDto { } export class MaintenanceStatusResponseDto { - action?: MaintenanceAction; + action!: MaintenanceAction; progress?: number; task?: string; diff --git a/server/src/maintenance/maintenance-ephemeral-state.repository.ts b/server/src/maintenance/maintenance-ephemeral-state.repository.ts index 56f8c318de..0dcc6785ef 100644 --- a/server/src/maintenance/maintenance-ephemeral-state.repository.ts +++ b/server/src/maintenance/maintenance-ephemeral-state.repository.ts @@ -1,10 +1,13 @@ import { Injectable } from '@nestjs/common'; import { MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto'; +import { MaintenanceAction } from 'src/enum'; @Injectable() export class MaintenanceEphemeralStateRepository { #secret: string = null!; - #state: MaintenanceStatusResponseDto = {}; + #state: MaintenanceStatusResponseDto = { + action: MaintenanceAction.Start, + }; setSecret(secret: string) { this.#secret = secret; @@ -14,15 +17,15 @@ export class MaintenanceEphemeralStateRepository { return this.#secret; } - setState(state: MaintenanceStatusResponseDto) { + setStatus(state: MaintenanceStatusResponseDto) { this.#state = state; } - getState(): MaintenanceStatusResponseDto { + getStatus(): MaintenanceStatusResponseDto { return this.#state; } - getPublicState(): MaintenanceStatusResponseDto { + getPublicStatus(): MaintenanceStatusResponseDto { const state = structuredClone(this.#state); if (state.error) { diff --git a/server/src/maintenance/maintenance-websocket.repository.ts b/server/src/maintenance/maintenance-websocket.repository.ts index 6bc57fa71e..f08ff811d8 100644 --- a/server/src/maintenance/maintenance-websocket.repository.ts +++ b/server/src/maintenance/maintenance-websocket.repository.ts @@ -7,19 +7,23 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; +import { MaintenanceAuthDto, MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto'; import { AppRepository } from 'src/repositories/app.repository'; -import { AppRestartEvent, ArgsOf } from 'src/repositories/event.repository'; +import { AppRestartEvent } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -export const serverEvents = ['AppRestart'] as const; -export type ServerEvents = (typeof serverEvents)[number]; +interface ServerEventMap { + AppRestart: [AppRestartEvent]; + MaintenanceStatus: [MaintenanceStatusResponseDto]; +} -export interface ClientEventMap { +interface ClientEventMap { AppRestartV1: [AppRestartEvent]; + MaintenanceStatusV1: [MaintenanceStatusResponseDto]; } type AuthFn = (client: Socket) => Promise; +type StatusUpdateFn = (status: MaintenanceStatusResponseDto) => void; @WebSocketGateway({ cors: true, @@ -29,9 +33,10 @@ type AuthFn = (client: Socket) => Promise; @Injectable() export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit { private authFn?: AuthFn; + private statusUpdateFn?: StatusUpdateFn; @WebSocketServer() - private websocketServer?: Server; + private server?: Server; constructor( private logger: LoggingRepository, @@ -40,18 +45,23 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa this.logger.setContext(MaintenanceWebsocketRepository.name); } - afterInit(websocketServer: Server) { + afterInit(server: Server) { this.logger.log('Initialized websocket server'); - websocketServer.on('AppRestart', () => this.appRepository.exitApp()); + server.on('AppRestart', () => this.appRepository.exitApp()); + server.on('MaintenanceStatus', (status) => this.statusUpdateFn?.(status)); + } + + clientSend(event: T, room: string, ...data: ClientEventMap[T]) { + this.server?.to(room).emit(event, ...data); } clientBroadcast(event: T, ...data: ClientEventMap[T]) { - this.websocketServer?.emit(event, ...data); + this.server?.emit(event, ...data); } - serverSend(event: T, ...args: ArgsOf): void { + serverSend(event: T, ...args: ServerEventMap[T]): void { this.logger.debug(`Server event: ${event} (send)`); - this.websocketServer?.serverSideEmit(event, ...args); + this.server?.serverSideEmit(event, ...args); } async handleConnection(client: Socket) { @@ -73,4 +83,8 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa setAuthFn(fn: (client: Socket) => Promise) { this.authFn = fn; } + + setStatusUpdateFn(fn: (status: MaintenanceStatusResponseDto) => void) { + this.statusUpdateFn = fn; + } } diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts index 051ffebbac..ad8b4aae1e 100644 --- a/server/src/maintenance/maintenance-worker.controller.ts +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -7,7 +7,7 @@ import { SetMaintenanceModeDto, } from 'src/dtos/maintenance.dto'; import { ServerConfigDto } from 'src/dtos/server.dto'; -import { ImmichCookie, MaintenanceAction } from 'src/enum'; +import { ImmichCookie } from 'src/enum'; import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service'; import { GetLoginDetails } from 'src/middleware/auth.guard'; @@ -46,8 +46,6 @@ export class MaintenanceWorkerController { @Post('admin/maintenance') @MaintenanceRoute() async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise { - if (dto.action === MaintenanceAction.End) { - await this.service.endMaintenance(); - } + await this.service.runAction(dto); } } diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index 14089bdc4d..2280a05b8f 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -4,9 +4,9 @@ import { NextFunction, Request, Response } from 'express'; import { jwtVerify } from 'jose'; import { readFileSync } from 'node:fs'; import { IncomingHttpHeaders } from 'node:http'; -import { MaintenanceAuthDto, MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto'; +import { MaintenanceAuthDto, MaintenanceStatusResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { ServerConfigDto } from 'src/dtos/server.dto'; -import { ImmichCookie, SystemMetadataKey } from 'src/enum'; +import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { AppRepository } from 'src/repositories/app.repository'; @@ -20,7 +20,7 @@ import { type ApiService as _ApiService } from 'src/services/api.service'; import { type BaseService as _BaseService } from 'src/services/base.service'; import { type ServerService as _ServerService } from 'src/services/server.service'; import { MaintenanceModeState } from 'src/types'; -import { deleteBackup, listBackups } from 'src/utils/backups'; +import { deleteBackup, listBackups, restoreBackup } from 'src/utils/backups'; import { getConfig } from 'src/utils/config'; import { createMaintenanceLoginUrl } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; @@ -35,7 +35,7 @@ export class MaintenanceWorkerService { private appRepository: AppRepository, private configRepository: ConfigRepository, private systemMetadataRepository: SystemMetadataRepository, - private maintenanceWorkerRepository: MaintenanceWebsocketRepository, + private maintenanceWebsocketRepository: MaintenanceWebsocketRepository, private maintenanceEphemeralStateRepository: MaintenanceEphemeralStateRepository, private storageRepository: StorageRepository, private processRepository: ProcessRepository, @@ -130,6 +130,17 @@ export class MaintenanceWorkerService { return '/usr/src/app/upload'; } + setStatus(status: MaintenanceStatusResponseDto): void { + this.maintenanceEphemeralStateRepository.setStatus(status); + this.maintenanceWebsocketRepository.serverSend('MaintenanceStatus', status); + this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'private', status); + this.maintenanceWebsocketRepository.clientSend( + 'MaintenanceStatusV1', + 'public', + this.maintenanceEphemeralStateRepository.getPublicStatus(), + ); + } + async logSecret(): Promise { const { server } = await this.getConfig({ withCache: true }); @@ -153,9 +164,9 @@ export class MaintenanceWorkerService { async status(potentiallyJwt?: string): Promise { try { await this.login(potentiallyJwt); - return this.maintenanceEphemeralStateRepository.getState(); + return this.maintenanceEphemeralStateRepository.getStatus(); } catch { - return this.maintenanceEphemeralStateRepository.getPublicState(); + return this.maintenanceEphemeralStateRepository.getPublicStatus(); } } @@ -174,13 +185,53 @@ export class MaintenanceWorkerService { } } + async runAction(action: SetMaintenanceModeDto) { + switch (action.action) { + case MaintenanceAction.Start: + return; + case MaintenanceAction.End: + return this.endMaintenance(); + case MaintenanceAction.RestoreDatabase: + if (!action.restoreBackupFilename) 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.maintenanceEphemeralStateRepository.getSecret(), + action: { + action: MaintenanceAction.Start, + }, + }); + + try { + switch (action.action) { + case MaintenanceAction.RestoreDatabase: + await this.restoreBackup(action.restoreBackupFilename); + break; + } + } catch (error) { + this.logger.error(`Encountered error running action: ${error}`); + this.setStatus({ + action: action.action, + error: '' + error, + }); + } + } + async endMaintenance(): Promise { const state: MaintenanceModeState = { isMaintenanceMode: false as const }; await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); // => corresponds to notification.service.ts#onAppRestart - this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state); - this.maintenanceWorkerRepository.serverSend('AppRestart', state); + this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state); + this.maintenanceWebsocketRepository.serverSend('AppRestart', state); this.appRepository.exitApp(); } @@ -188,6 +239,31 @@ export class MaintenanceWorkerService { * Backups */ + private async restoreBackup(filename: string): Promise { + this.setStatus({ + action: MaintenanceAction.RestoreDatabase, + progress: 0, + }); + + await restoreBackup(this.backupRepos, filename, (task, progress) => + this.setStatus({ + action: MaintenanceAction.RestoreDatabase, + progress, + task, + }), + ); + + this.setStatus({ + action: MaintenanceAction.End, + }); + + // => corresponds to notification.service.ts#onAppRestart + const state: MaintenanceModeState = { isMaintenanceMode: false }; + this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state); + this.maintenanceWebsocketRepository.serverSend('AppRestart', state); + this.appRepository.exitApp(); + } + async listBackups(): Promise> { return listBackups(this.backupRepos); }