feat: system integrity check in restore flow

This commit is contained in:
izzy
2025-11-21 16:37:28 +00:00
parent d2a4dd67d8
commit cbf3a2c3cb
14 changed files with 510 additions and 25 deletions

View File

@@ -17,6 +17,7 @@ import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
MaintenanceAuthDto,
MaintenanceIntegrityResponseDto,
MaintenanceListBackupsResponseDto,
MaintenanceLoginDto,
MaintenanceStatusResponseDto,
@@ -25,15 +26,20 @@ import {
} from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
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';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(private service: MaintenanceService) {}
constructor(
private service: MaintenanceService,
private storageRepository: StorageRepository,
) {}
@Get('status')
@Endpoint({
@@ -47,6 +53,16 @@ export class MaintenanceController {
};
}
@Get('integrity')
@Endpoint({
summary: 'Get integrity and heuristics',
description: 'Collect integrity checks and other heuristics about local data.',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
return integrityCheck(this.storageRepository);
}
@Post('login')
@Endpoint({
summary: 'Log into maintenance mode',

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { MaintenanceAction } from 'src/enum';
import { MaintenanceAction, StorageFolder } from 'src/enum';
import { ValidateEnum, ValidateString } from 'src/validation';
export class SetMaintenanceModeDto {
@@ -28,6 +28,22 @@ export class MaintenanceStatusResponseDto {
error?: string;
}
export class MaintenanceIntegrityResponseDto {
storageIntegrity!: Record<
StorageFolder,
{
readable: boolean;
writable: boolean;
}
>;
storageHeuristics!: Record<
StorageFolder,
{
files: number;
}
>;
}
export class MaintenanceListBackupsResponseDto {
backups!: string[];
}

View File

@@ -3,6 +3,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { Request, Response } from 'express';
import {
MaintenanceAuthDto,
MaintenanceIntegrityResponseDto,
MaintenanceListBackupsResponseDto,
MaintenanceLoginDto,
MaintenanceStatusResponseDto,
@@ -13,13 +14,18 @@ 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';
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';
@Controller()
export class MaintenanceWorkerController {
constructor(private service: MaintenanceWorkerService) {}
constructor(
private service: MaintenanceWorkerService,
private storageRepository: StorageRepository,
) {}
@Get('server/config')
getServerConfig(): ServerConfigDto {
@@ -31,6 +37,11 @@ export class MaintenanceWorkerController {
return this.service.status(request.cookies[ImmichCookie.MaintenanceToken]);
}
@Get('admin/maintenance/integrity')
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
return integrityCheck(this.storageRepository);
}
@Post('admin/maintenance/login')
async maintenanceLogin(
@Req() request: Request,

View File

@@ -2,10 +2,14 @@ import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { Server as SocketIO } from 'socket.io';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto, MaintenanceIntegrityResponseDto } from 'src/dtos/maintenance.dto';
import { StorageFolder } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
@@ -72,3 +76,47 @@ export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDt
export function generateMaintenanceSecret(): string {
return randomBytes(64).toString('hex');
}
export async function integrityCheck(storageRepository: StorageRepository): Promise<MaintenanceIntegrityResponseDto> {
return {
storageIntegrity: Object.fromEntries(
await Promise.all(
Object.values(StorageFolder).map(async (folder) => {
const path = join(StorageCore.getBaseFolder(folder), '.immich');
try {
await storageRepository.readFile(path);
try {
await storageRepository.overwriteFile(path, Buffer.from(`${Date.now()}`));
return [folder, { readable: true, writable: true }];
} catch {
return [folder, { readable: true, writable: false }];
}
} catch {
return [folder, { readable: false, writable: false }];
}
}),
),
),
storageHeuristics: Object.fromEntries(
await Promise.all(
Object.values(StorageFolder).map(async (folder) => {
const path = StorageCore.getBaseFolder(folder);
const files = await storageRepository.readdir(path);
try {
return [
folder,
{
files: files.filter((fn) => fn !== '.immich').length,
},
];
} catch {
return [folder, { files: 0 }];
}
}),
),
),
};
}