diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index 7b2aa17582..e7f1dd9a86 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -38,8 +38,8 @@ export class MaintenanceController { @GetLoginDetails() loginDetails: LoginDetails, @Res({ passthrough: true }) res: Response, ): Promise { - if (dto.action === MaintenanceAction.Start) { - const { jwt } = await this.service.startMaintenance(auth.user.name); + if (dto.action !== MaintenanceAction.End) { + const { jwt } = await this.service.startMaintenance(dto, auth.user.name); return respondWithCookie(res, undefined, { isSecure: loginDetails.isSecure, values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }], diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts index fe6960c0a4..59c4646439 100644 --- a/server/src/dtos/maintenance.dto.ts +++ b/server/src/dtos/maintenance.dto.ts @@ -4,6 +4,9 @@ import { ValidateEnum, ValidateString } from 'src/validation'; export class SetMaintenanceModeDto { @ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' }) action!: MaintenanceAction; + + @ValidateString({ optional: true }) + restoreBackupFilename?: string; } export class MaintenanceLoginDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index 87e0aa5e90..dcdc09fd7e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -664,7 +664,6 @@ export enum DatabaseLock { export enum MaintenanceAction { Start = 'start', End = 'end', - RestoreFlow = 'restore_flow', RestoreDatabase = 'restore_database', } diff --git a/server/src/maintenance/maintenance-worker.service.spec.ts b/server/src/maintenance/maintenance-worker.service.spec.ts index dd5b984214..8a68025678 100644 --- a/server/src/maintenance/maintenance-worker.service.spec.ts +++ b/server/src/maintenance/maintenance-worker.service.spec.ts @@ -1,6 +1,6 @@ import { UnauthorizedException } from '@nestjs/common'; import { SignJWT } from 'jose'; -import { SystemMetadataKey } from 'src/enum'; +import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service'; import { automock, getMocks, ServiceMocks } from 'test/utils'; @@ -19,6 +19,9 @@ describe(MaintenanceWorkerService.name, () => { mocks.config, mocks.systemMetadata as never, maintenanceWorkerRepositoryMock, + mocks.storage as never, + mocks.process, + mocks.database as never, ); }); @@ -42,7 +45,14 @@ describe(MaintenanceWorkerService.name, () => { const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/; it('should log a valid login URL', async () => { - mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' }); + mocks.systemMetadata.get.mockResolvedValue({ + isMaintenanceMode: true, + secret: 'secret', + action: { + action: MaintenanceAction.Start, + }, + }); + await expect(sut.logSecret()).resolves.toBeUndefined(); expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL)); @@ -63,7 +73,13 @@ describe(MaintenanceWorkerService.name, () => { }); it('should parse cookie properly', async () => { - mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' }); + mocks.systemMetadata.get.mockResolvedValue({ + isMaintenanceMode: true, + secret: 'secret', + action: { + action: MaintenanceAction.Start, + }, + }); await expect( sut.authenticate({ @@ -79,7 +95,13 @@ describe(MaintenanceWorkerService.name, () => { }); it('should fail with expired JWT', async () => { - mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' }); + mocks.systemMetadata.get.mockResolvedValue({ + isMaintenanceMode: true, + secret: 'secret', + action: { + action: MaintenanceAction.Start, + }, + }); const jwt = await new SignJWT({}) .setProtectedHeader({ alg: 'HS256' }) @@ -91,7 +113,13 @@ describe(MaintenanceWorkerService.name, () => { }); it('should succeed with valid JWT', async () => { - mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' }); + mocks.systemMetadata.get.mockResolvedValue({ + isMaintenanceMode: true, + secret: 'secret', + action: { + action: MaintenanceAction.Start, + }, + }); const jwt = await new SignJWT({ _mockValue: true }) .setProtectedHeader({ alg: 'HS256' }) diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index c03231c274..2a9605a2a9 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -9,12 +9,16 @@ import { ImmichCookie, SystemMetadataKey } from 'src/enum'; 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 { type ServerService as _ServerService } from 'src/services/server.service'; import { MaintenanceModeState } from 'src/types'; +import { deleteBackup, listBackups } from 'src/utils/backups'; import { getConfig } from 'src/utils/config'; import { createMaintenanceLoginUrl } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; @@ -30,6 +34,9 @@ export class MaintenanceWorkerService { private configRepository: ConfigRepository, private systemMetadataRepository: SystemMetadataRepository, private maintenanceWorkerRepository: MaintenanceWebsocketRepository, + private storageRepository: StorageRepository, + private processRepository: ProcessRepository, + private databaseRepository: DatabaseRepository, ) { this.logger.setContext(this.constructor.name); } @@ -158,4 +165,26 @@ export class MaintenanceWorkerService { this.maintenanceWorkerRepository.serverSend('AppRestart', state); this.appRepository.exitApp(); } + + /** + * Backups + */ + + async listBackups(): Promise> { + return listBackups(this.backupRepos); + } + + async deleteBackup(filename: string): Promise { + return deleteBackup(this.backupRepos, filename); + } + + private get backupRepos() { + return { + logger: this.logger, + storage: this.storageRepository, + config: this.configRepository, + process: this.processRepository, + database: this.databaseRepository, + }; + } } diff --git a/server/src/services/maintenance.service.spec.ts b/server/src/services/maintenance.service.spec.ts index cc497a6ea4..575ad91d5e 100644 --- a/server/src/services/maintenance.service.spec.ts +++ b/server/src/services/maintenance.service.spec.ts @@ -1,4 +1,6 @@ -import { SystemMetadataKey } from 'src/enum'; +import { DateTime } from 'luxon'; +import { StorageCore } from 'src/cores/storage.core'; +import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; import { MaintenanceService } from 'src/services/maintenance.service'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -36,11 +38,18 @@ describe(MaintenanceService.name, () => { }); it('should return true if enabled', async () => { - mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: '' }); + mocks.systemMetadata.get.mockResolvedValue({ + isMaintenanceMode: true, + secret: '', + action: { action: MaintenanceAction.Start }, + }); await expect(sut.getMaintenanceMode()).resolves.toEqual({ isMaintenanceMode: true, secret: '', + action: { + action: 'start', + }, }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); @@ -51,13 +60,23 @@ describe(MaintenanceService.name, () => { it('should set maintenance mode and return a secret', async () => { mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false }); - await expect(sut.startMaintenance('admin')).resolves.toMatchObject({ + await expect( + sut.startMaintenance( + { + action: MaintenanceAction.Start, + }, + 'admin', + ), + ).resolves.toMatchObject({ jwt: expect.any(String), }); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret: expect.stringMatching(/^\w{128}$/), + action: { + action: 'start', + }, }); expect(mocks.event.emit).toHaveBeenCalledWith('AppRestart', { @@ -78,7 +97,13 @@ describe(MaintenanceService.name, () => { }); it('should generate a login url with JWT', async () => { - mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' }); + mocks.systemMetadata.get.mockResolvedValue({ + isMaintenanceMode: true, + secret: 'secret', + action: { + action: MaintenanceAction.Start, + }, + }); await expect( sut.createLoginUrl({ @@ -106,4 +131,36 @@ describe(MaintenanceService.name, () => { expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1); }); }); + + /** + * Backups + */ + + describe('listBackups', () => { + it('should give us all valid and failed backups', async () => { + mocks.storage.readdir.mockResolvedValue([ + `immich-db-backup-${DateTime.fromISO('2025-07-25T11:02:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz.tmp`, + `immich-db-backup-${DateTime.fromISO('2025-07-27T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`, + 'immich-db-backup-1753789649000.sql.gz', + `immich-db-backup-${DateTime.fromISO('2025-07-29T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`, + ]); + + await expect(sut.listBackups()).resolves.toMatchObject({ + backups: [ + 'immich-db-backup-20250729T110116-v1.234.5-pg14.5.sql.gz', + 'immich-db-backup-20250727T110116-v1.234.5-pg14.5.sql.gz', + 'immich-db-backup-1753789649000.sql.gz', + ], + failedBackups: ['immich-db-backup-20250725T110216-v1.234.5-pg14.5.sql.gz.tmp'], + }); + }); + }); + + describe('deleteBackup', () => { + it('should unlink the target file', async () => { + await sut.deleteBackup('filename'); + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`); + }); + }); }); diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index e6808300bc..57ad90a053 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from 'src/decorators'; -import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; +import { MaintenanceAuthDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { MaintenanceModeState } from 'src/types'; +import { deleteBackup, listBackups } from 'src/utils/backups'; import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; @@ -18,9 +19,14 @@ export class MaintenanceService extends BaseService { .then((state) => state ?? { isMaintenanceMode: false }); } - async startMaintenance(username: string): Promise<{ jwt: string }> { + async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> { const secret = generateMaintenanceSecret(); - await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret }); + await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { + isMaintenanceMode: true, + secret, + action, + }); + await this.eventRepository.emit('AppRestart', { isMaintenanceMode: true }); return { @@ -50,4 +56,26 @@ export class MaintenanceService extends BaseService { return await createMaintenanceLoginUrl(baseUrl, auth, secret); } + + /** + * Backups + */ + + async listBackups(): Promise> { + return listBackups(this.backupRepos); + } + + async deleteBackup(filename: string): Promise { + return deleteBackup(this.backupRepos, filename); + } + + private get backupRepos() { + return { + logger: this.logger, + storage: this.storageRepository, + config: this.configRepository, + process: this.processRepository, + database: this.databaseRepository, + }; + } } diff --git a/server/src/types.ts b/server/src/types.ts index dd3d25a7cb..2a306633f0 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -3,6 +3,7 @@ import { VECTOR_EXTENSIONS } from 'src/constants'; import { Asset } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { AssetMetadataKey, AssetOrder, @@ -493,7 +494,9 @@ export interface MemoryData { export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; export type SystemFlags = { mountChecks: Record }; -export type MaintenanceModeState = { isMaintenanceMode: true; secret: string } | { isMaintenanceMode: false }; +export type MaintenanceModeState = + | { isMaintenanceMode: true; secret: string; action: SetMaintenanceModeDto } + | { isMaintenanceMode: false }; export type MemoriesState = { /** memories have already been created through this date */ lastOnThisDayDate: string;