diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index b3e4fc2731..28339daf4f 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -29,7 +29,6 @@ import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middlewa import { StorageRepository } from 'src/repositories/storage.repository'; import { LoginDetails } from 'src/services/auth.service'; import { MaintenanceService } from 'src/services/maintenance.service'; -import { integrityCheck } from 'src/utils/maintenance'; import { respondWithCookie } from 'src/utils/response'; import { FilenameParamDto } from 'src/validation'; @@ -60,7 +59,7 @@ export class MaintenanceController { history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), }) integrityCheck(): Promise { - return integrityCheck(this.storageRepository); + return this.service.integrityCheck(); } @Post('login') diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts index 331c1a19ea..2b4a557877 100644 --- a/server/src/maintenance/maintenance-worker.controller.ts +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -16,7 +16,6 @@ import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.ser import { GetLoginDetails } from 'src/middleware/auth.guard'; import { StorageRepository } from 'src/repositories/storage.repository'; import { LoginDetails } from 'src/services/auth.service'; -import { integrityCheck } from 'src/utils/maintenance'; import { respondWithCookie } from 'src/utils/response'; import { FilenameParamDto } from 'src/validation'; @@ -39,7 +38,7 @@ export class MaintenanceWorkerController { @Get('admin/maintenance/integrity') integrityCheck(): Promise { - return integrityCheck(this.storageRepository); + return this.service.integrityCheck(); } @Post('admin/maintenance/login') diff --git a/server/src/maintenance/maintenance-worker.service.spec.ts b/server/src/maintenance/maintenance-worker.service.spec.ts index f13a2b9148..6b2365938a 100644 --- a/server/src/maintenance/maintenance-worker.service.spec.ts +++ b/server/src/maintenance/maintenance-worker.service.spec.ts @@ -1,4 +1,4 @@ -import { UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { SignJWT } from 'jose'; import { DateTime } from 'luxon'; import { PassThrough, Readable } from 'node:stream'; @@ -59,6 +59,32 @@ describe(MaintenanceWorkerService.name, () => { }); }); + describe.skip('ssr'); + describe.skip('detectMediaLocation'); + + describe('setStatus', () => { + it('should broadcast status', async () => { + maintenanceEphemeralStateRepositoryMock.getPublicStatus.mockReturnValue({ + action: MaintenanceAction.Start, + error: 'mock', + }); + + sut.setStatus({ + action: MaintenanceAction.Start, + task: 'abc', + error: 'def', + }); + + expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenCalled(); + expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalled(); + expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledTimes(2); + expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'public', { + action: 'start', + error: 'mock', + }); + }); + }); + describe('logSecret', () => { const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/; @@ -107,6 +133,95 @@ describe(MaintenanceWorkerService.name, () => { }); }); + describe('status', () => { + beforeEach(() => { + maintenanceEphemeralStateRepositoryMock.getStatus.mockResolvedValue({ + action: MaintenanceAction.Start, + error: 'secret value!', + }); + + maintenanceEphemeralStateRepositoryMock.getPublicStatus.mockResolvedValue({ + action: MaintenanceAction.Start, + error: 'public mock', + }); + }); + + it('generates private status', async () => { + maintenanceEphemeralStateRepositoryMock.getSecret.mockReturnValue('secret'); + + const jwt = await new SignJWT({ _mockValue: true }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('4h') + .sign(new TextEncoder().encode('secret')); + + await expect(sut.status(jwt)).resolves.toEqual( + expect.objectContaining({ + error: 'secret value!', + }), + ); + }); + + it('generates public status', async () => { + await expect(sut.status()).resolves.toEqual( + expect.objectContaining({ + error: 'public mock', + }), + ); + }); + }); + + describe('integrityCheck', () => { + it('generate integrity report', async () => { + mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']); + mocks.storage.readFile.mockResolvedValue(undefined as never); + mocks.storage.overwriteFile.mockRejectedValue(undefined as never); + + await expect(sut.integrityCheck()).resolves.toMatchInlineSnapshot(` + { + "storage": [ + { + "files": 2, + "folder": "encoded-video", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "library", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "upload", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "profile", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "thumbs", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "backups", + "readable": true, + "writable": false, + }, + ], + } + `); + }); + }); + describe('login', () => { it('should fail without token', async () => { await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token')); @@ -155,7 +270,23 @@ describe(MaintenanceWorkerService.name, () => { }); }); - describe('endMaintenance', () => { + describe.skip('setAction'); // just calls setStatus+runAction + + /** + * Actions + */ + + describe('action: start', () => { + it('should not do anything', async () => { + await sut.runAction({ + action: MaintenanceAction.Start, + }); + + expect(mocks.logger.log).toHaveBeenCalledTimes(0); + }); + }); + + describe('action: end', () => { it('should set maintenance mode', async () => { mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false }); await sut.runAction({ @@ -176,20 +307,6 @@ describe(MaintenanceWorkerService.name, () => { }); }); - /** - * Actions - */ - - describe('action: start', () => { - it('should not do anything', async () => { - await sut.runAction({ - action: MaintenanceAction.Start, - }); - - expect(mocks.logger.log).toHaveBeenCalledTimes(0); - }); - }); - describe('action: restore database', () => { beforeEach(() => { mocks.database.tryLock.mockResolvedValueOnce(true); @@ -319,4 +436,27 @@ describe(MaintenanceWorkerService.name, () => { expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`); }); }); + + describe('uploadBackup', () => { + it('should reject invalid file names', async () => { + await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError( + new BadRequestException('Not a valid backup name!'), + ); + }); + + it('should write file', async () => { + await sut.uploadBackup({ originalname: 'path.sql.gz' } as never); + expect(mocks.storage.overwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', undefined); + }); + }); + + describe('getBackupPath', () => { + it('should reject invalid file names', () => { + expect(() => sut.getBackupPath('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!')); + }); + + it('should get backup path', () => { + expect(sut.getBackupPath('hello.sql.gz')).toEqual('/data/backups/hello.sql.gz'); + }); + }); }); diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index e0311aec2f..9fe45b63be 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -6,7 +6,12 @@ import { readFileSync } from 'node:fs'; import { IncomingHttpHeaders } from 'node:http'; import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; -import { MaintenanceAuthDto, MaintenanceStatusResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; +import { + MaintenanceAuthDto, + MaintenanceIntegrityResponseDto, + MaintenanceStatusResponseDto, + SetMaintenanceModeDto, +} from 'src/dtos/maintenance.dto'; import { ServerConfigDto } from 'src/dtos/server.dto'; import { DatabaseLock, ImmichCookie, MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository'; @@ -24,7 +29,7 @@ import { type ServerService as _ServerService } from 'src/services/server.servic import { MaintenanceModeState } from 'src/types'; import { deleteBackup, isValidBackupName, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups'; import { getConfig } from 'src/utils/config'; -import { createMaintenanceLoginUrl } from 'src/utils/maintenance'; +import { createMaintenanceLoginUrl, integrityCheck } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; /** @@ -172,6 +177,10 @@ export class MaintenanceWorkerService { } } + integrityCheck(): Promise { + return integrityCheck(this.storageRepository); + } + async login(jwt?: string): Promise { if (!jwt) { throw new UnauthorizedException('Missing JWT Token'); @@ -287,7 +296,7 @@ export class MaintenanceWorkerService { } async uploadBackup(file: Express.Multer.File): Promise { - return uploadBackup(file); + return uploadBackup(this.backupRepos, file); } getBackupPath(filename: string): string { diff --git a/server/src/services/maintenance.service.spec.ts b/server/src/services/maintenance.service.spec.ts index 51e926ce33..cbe74e7b01 100644 --- a/server/src/services/maintenance.service.spec.ts +++ b/server/src/services/maintenance.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { DateTime } from 'luxon'; import { StorageCore } from 'src/cores/storage.core'; import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; @@ -56,6 +57,57 @@ describe(MaintenanceService.name, () => { }); }); + describe('integrityCheck', () => { + it('generate integrity report', async () => { + mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']); + mocks.storage.readFile.mockResolvedValue(undefined as never); + mocks.storage.overwriteFile.mockRejectedValue(undefined as never); + + await expect(sut.integrityCheck()).resolves.toMatchInlineSnapshot(` + { + "storage": [ + { + "files": 2, + "folder": "encoded-video", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "library", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "upload", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "profile", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "thumbs", + "readable": true, + "writable": false, + }, + { + "files": 2, + "folder": "backups", + "readable": true, + "writable": false, + }, + ], + } + `); + }); + }); + describe('startMaintenance', () => { it('should set maintenance mode and return a secret', async () => { mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false }); @@ -137,7 +189,7 @@ describe(MaintenanceService.name, () => { */ describe('listBackups', () => { - it('should give us all valid and failed backups', async () => { + it('should give us all 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`, @@ -162,4 +214,27 @@ describe(MaintenanceService.name, () => { expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`); }); }); + + describe('uploadBackup', () => { + it('should reject invalid file names', async () => { + await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError( + new BadRequestException('Not a valid backup name!'), + ); + }); + + it('should write file', async () => { + await sut.uploadBackup({ originalname: 'path.sql.gz' } as never); + expect(mocks.storage.overwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', undefined); + }); + }); + + describe('getBackupPath', () => { + it('should reject invalid file names', () => { + expect(() => sut.getBackupPath('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!')); + }); + + it('should get backup path', () => { + expect(sut.getBackupPath('hello.sql.gz')).toEqual('/data/backups/hello.sql.gz'); + }); + }); }); diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index eef508c3c6..983d0092c2 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -2,12 +2,17 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { basename, join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; -import { MaintenanceAuthDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; +import { MaintenanceAuthDto, MaintenanceIntegrityResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { MaintenanceModeState } from 'src/types'; import { deleteBackup, isValidBackupName, listBackups, uploadBackup } from 'src/utils/backups'; -import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; +import { + createMaintenanceLoginUrl, + generateMaintenanceSecret, + integrityCheck, + signMaintenanceJwt, +} from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; /** @@ -21,6 +26,10 @@ export class MaintenanceService extends BaseService { .then((state) => state ?? { isMaintenanceMode: false }); } + integrityCheck(): Promise { + return integrityCheck(this.storageRepository); + } + async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> { const secret = generateMaintenanceSecret(); await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { @@ -86,7 +95,7 @@ export class MaintenanceService extends BaseService { } async uploadBackup(file: Express.Multer.File): Promise { - return uploadBackup(file); + return uploadBackup(this.backupRepos, file); } getBackupPath(filename: string): string { diff --git a/server/src/utils/backups.ts b/server/src/utils/backups.ts index 35d9a0a7d9..a79e591371 100644 --- a/server/src/utils/backups.ts +++ b/server/src/utils/backups.ts @@ -1,8 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { debounce } from 'lodash'; import { DateTime } from 'luxon'; -import { stat, writeFile } from 'node:fs/promises'; -import path, { join } from 'node:path'; +import path, { basename, join } from 'node:path'; import { PassThrough, Readable, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import semver from 'semver'; @@ -273,21 +272,18 @@ export async function listBackups({ storage }: Pick): Pr .toReversed(); } -export async function uploadBackup(file: Express.Multer.File): Promise { +export async function uploadBackup( + { storage }: Pick, + file: Express.Multer.File, +): Promise { const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); - const fn = file.originalname; + const fn = basename(file.originalname); if (!isValidBackupName(fn)) { throw new BadRequestException('Not a valid backup name!'); } const path = join(backupsFolder, `uploaded-${fn}`); - - try { - await stat(path); - throw new BadRequestException('File already exists!'); - } catch { - await writeFile(path, file.buffer); - } + await storage.overwriteFile(path, file.buffer); } function createSqlProgressStreams(cb: (progress: number) => void) {