diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3862be3cf3..5305f02d32 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -160,6 +160,8 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings *MaintenanceAdminApi* | [**deleteBackup**](doc//MaintenanceAdminApi.md#deletebackup) | **DELETE** /admin/maintenance/backups/{filename} | Delete backup +*MaintenanceAdminApi* | [**downloadBackup**](doc//MaintenanceAdminApi.md#downloadbackup) | **GET** /admin/maintenance/backups/{filename} | Download backup +*MaintenanceAdminApi* | [**integrityCheck**](doc//MaintenanceAdminApi.md#integritycheck) | **GET** /admin/maintenance/integrity | Get integrity and heuristics *MaintenanceAdminApi* | [**listBackups**](doc//MaintenanceAdminApi.md#listbackups) | **GET** /admin/maintenance/backups/list | List backups *MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode *MaintenanceAdminApi* | [**maintenanceStatus**](doc//MaintenanceAdminApi.md#maintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status @@ -413,6 +415,7 @@ Class | Method | HTTP request | Description - [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md) - [MaintenanceAction](doc//MaintenanceAction.md) - [MaintenanceAuthDto](doc//MaintenanceAuthDto.md) + - [MaintenanceIntegrityResponseDto](doc//MaintenanceIntegrityResponseDto.md) - [MaintenanceListBackupsResponseDto](doc//MaintenanceListBackupsResponseDto.md) - [MaintenanceLoginDto](doc//MaintenanceLoginDto.md) - [MaintenanceStatusResponseDto](doc//MaintenanceStatusResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7ca4bcdb83..7361f0f7e3 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -166,6 +166,7 @@ part 'model/logout_response_dto.dart'; part 'model/machine_learning_availability_checks_dto.dart'; part 'model/maintenance_action.dart'; part 'model/maintenance_auth_dto.dart'; +part 'model/maintenance_integrity_response_dto.dart'; part 'model/maintenance_list_backups_response_dto.dart'; part 'model/maintenance_login_dto.dart'; part 'model/maintenance_status_response_dto.dart'; diff --git a/mobile/openapi/lib/api/maintenance_admin_api.dart b/mobile/openapi/lib/api/maintenance_admin_api.dart index ecef84b691..8c6afc90cf 100644 --- a/mobile/openapi/lib/api/maintenance_admin_api.dart +++ b/mobile/openapi/lib/api/maintenance_admin_api.dart @@ -65,6 +65,111 @@ class MaintenanceAdminApi { } } + /// Download backup + /// + /// Downloads the database backup file + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] filename (required): + Future downloadBackupWithHttpInfo(String filename,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/maintenance/backups/{filename}' + .replaceAll('{filename}', filename); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Download backup + /// + /// Downloads the database backup file + /// + /// Parameters: + /// + /// * [String] filename (required): + Future downloadBackup(String filename,) async { + final response = await downloadBackupWithHttpInfo(filename,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; + + } + return null; + } + + /// Get integrity and heuristics + /// + /// Collect integrity checks and other heuristics about local data. + /// + /// Note: This method returns the HTTP [Response]. + Future integrityCheckWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/maintenance/integrity'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Get integrity and heuristics + /// + /// Collect integrity checks and other heuristics about local data. + Future integrityCheck() async { + final response = await integrityCheckWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceIntegrityResponseDto',) as MaintenanceIntegrityResponseDto; + + } + return null; + } + /// List backups /// /// Get the list of the successful and failed backups diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 6fd2af7dfc..19f835b6a5 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -382,6 +382,8 @@ class ApiClient { return MaintenanceActionTypeTransformer().decode(value); case 'MaintenanceAuthDto': return MaintenanceAuthDto.fromJson(value); + case 'MaintenanceIntegrityResponseDto': + return MaintenanceIntegrityResponseDto.fromJson(value); case 'MaintenanceListBackupsResponseDto': return MaintenanceListBackupsResponseDto.fromJson(value); case 'MaintenanceLoginDto': diff --git a/mobile/openapi/lib/model/maintenance_integrity_response_dto.dart b/mobile/openapi/lib/model/maintenance_integrity_response_dto.dart new file mode 100644 index 0000000000..fc07764657 --- /dev/null +++ b/mobile/openapi/lib/model/maintenance_integrity_response_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MaintenanceIntegrityResponseDto { + /// Returns a new [MaintenanceIntegrityResponseDto] instance. + MaintenanceIntegrityResponseDto({ + required this.storageHeuristics, + required this.storageIntegrity, + }); + + Object storageHeuristics; + + Object storageIntegrity; + + @override + bool operator ==(Object other) => identical(this, other) || other is MaintenanceIntegrityResponseDto && + other.storageHeuristics == storageHeuristics && + other.storageIntegrity == storageIntegrity; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (storageHeuristics.hashCode) + + (storageIntegrity.hashCode); + + @override + String toString() => 'MaintenanceIntegrityResponseDto[storageHeuristics=$storageHeuristics, storageIntegrity=$storageIntegrity]'; + + Map toJson() { + final json = {}; + json[r'storageHeuristics'] = this.storageHeuristics; + json[r'storageIntegrity'] = this.storageIntegrity; + return json; + } + + /// Returns a new [MaintenanceIntegrityResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MaintenanceIntegrityResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MaintenanceIntegrityResponseDto"); + if (value is Map) { + final json = value.cast(); + + return MaintenanceIntegrityResponseDto( + storageHeuristics: mapValueOfType(json, r'storageHeuristics')!, + storageIntegrity: mapValueOfType(json, r'storageIntegrity')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MaintenanceIntegrityResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MaintenanceIntegrityResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MaintenanceIntegrityResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MaintenanceIntegrityResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'storageHeuristics', + 'storageIntegrity', + }; +} + diff --git a/mobile/openapi/lib/model/maintenance_list_backups_response_dto.dart b/mobile/openapi/lib/model/maintenance_list_backups_response_dto.dart index de13b15ab9..0d5b6ba409 100644 --- a/mobile/openapi/lib/model/maintenance_list_backups_response_dto.dart +++ b/mobile/openapi/lib/model/maintenance_list_backups_response_dto.dart @@ -14,31 +14,25 @@ class MaintenanceListBackupsResponseDto { /// Returns a new [MaintenanceListBackupsResponseDto] instance. MaintenanceListBackupsResponseDto({ this.backups = const [], - this.failedBackups = const [], }); List backups; - List failedBackups; - @override bool operator ==(Object other) => identical(this, other) || other is MaintenanceListBackupsResponseDto && - _deepEquality.equals(other.backups, backups) && - _deepEquality.equals(other.failedBackups, failedBackups); + _deepEquality.equals(other.backups, backups); @override int get hashCode => // ignore: unnecessary_parenthesis - (backups.hashCode) + - (failedBackups.hashCode); + (backups.hashCode); @override - String toString() => 'MaintenanceListBackupsResponseDto[backups=$backups, failedBackups=$failedBackups]'; + String toString() => 'MaintenanceListBackupsResponseDto[backups=$backups]'; Map toJson() { final json = {}; json[r'backups'] = this.backups; - json[r'failedBackups'] = this.failedBackups; return json; } @@ -54,9 +48,6 @@ class MaintenanceListBackupsResponseDto { backups: json[r'backups'] is Iterable ? (json[r'backups'] as Iterable).cast().toList(growable: false) : const [], - failedBackups: json[r'failedBackups'] is Iterable - ? (json[r'failedBackups'] as Iterable).cast().toList(growable: false) - : const [], ); } return null; @@ -105,7 +96,6 @@ class MaintenanceListBackupsResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'backups', - 'failedBackups', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 9fead8d516..e7901bce3f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -603,6 +603,40 @@ "x-immich-state": "Alpha" } }, + "/admin/maintenance/integrity": { + "get": { + "description": "Collect integrity checks and other heuristics about local data.", + "operationId": "integrityCheck", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceIntegrityResponseDto" + } + } + }, + "description": "" + } + }, + "summary": "Get integrity and heuristics", + "tags": [ + "Maintenance (admin)" + ], + "x-immich-history": [ + { + "version": "v9.9.9", + "state": "Added" + }, + { + "version": "v9.9.9", + "state": "Alpha" + } + ], + "x-immich-state": "Alpha" + } + }, "/admin/maintenance/login": { "post": { "description": "Login with maintenance token or cookie to receive current information and perform further actions.", @@ -16807,6 +16841,21 @@ ], "type": "object" }, + "MaintenanceIntegrityResponseDto": { + "properties": { + "storageHeuristics": { + "type": "object" + }, + "storageIntegrity": { + "type": "object" + } + }, + "required": [ + "storageHeuristics", + "storageIntegrity" + ], + "type": "object" + }, "MaintenanceListBackupsResponseDto": { "properties": { "backups": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 022d7ac5f8..2ade47e79e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -46,11 +46,14 @@ export type SetMaintenanceModeDto = { }; export type MaintenanceListBackupsResponseDto = { backups: string[]; - failedBackups: string[]; }; export type MaintenanceUploadBackupDto = { file?: Blob; }; +export type MaintenanceIntegrityResponseDto = { + storageHeuristics: object; + storageIntegrity: object; +}; export type MaintenanceLoginDto = { token?: string; }; @@ -1901,6 +1904,30 @@ export function deleteBackup({ filename }: { method: "DELETE" })); } +/** + * Download backup + */ +export function downloadBackup({ filename }: { + filename: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/admin/maintenance/backups/${encodeURIComponent(filename)}`, { + ...opts + })); +} +/** + * Get integrity and heuristics + */ +export function integrityCheck(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MaintenanceIntegrityResponseDto; + }>("/admin/maintenance/integrity", { + ...opts + })); +} /** * Log into maintenance mode */ diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index f9846be773..cf938d9c9b 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -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 { + return integrityCheck(this.storageRepository); + } + @Post('login') @Endpoint({ summary: 'Log into maintenance mode', diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts index 360295d82b..2c87cf69b0 100644 --- a/server/src/dtos/maintenance.dto.ts +++ b/server/src/dtos/maintenance.dto.ts @@ -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[]; } diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts index a8b71ccc15..208a75e212 100644 --- a/server/src/maintenance/maintenance-worker.controller.ts +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -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 { + return integrityCheck(this.storageRepository); + } + @Post('admin/maintenance/login') async maintenanceLogin( @Req() request: Request, diff --git a/server/src/utils/maintenance.ts b/server/src/utils/maintenance.ts index 22de2e4083..869214ade5 100644 --- a/server/src/utils/maintenance.ts +++ b/server/src/utils/maintenance.ts @@ -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 { + 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 }]; + } + }), + ), + ), + }; +} diff --git a/web/src/lib/components/maintenance/MaintenanceRestoreFlow.svelte b/web/src/lib/components/maintenance/MaintenanceRestoreFlow.svelte new file mode 100644 index 0000000000..5d26137630 --- /dev/null +++ b/web/src/lib/components/maintenance/MaintenanceRestoreFlow.svelte @@ -0,0 +1,111 @@ + + +{#if stage === 0} + Restore Your Library + Before restoring a database backup, you must ensure your library has been restored or is otherwise already present. + + + + {#if integrity} + {#each Object.entries(integrity.storageIntegrity) as [folder, { readable, writable }]} + + + {i18nMap[folder as keyof typeof i18nMap]} ({writable + ? 'readable and writable' + : readable + ? 'not writable' + : 'not readable'}) + + {/each} + {#each Object.entries(integrity.storageHeuristics) as [folder, { files }]} + {#if folder !== 'backups'} + + + + + {#if files} + {i18nMap[folder as keyof typeof i18nMap]} has {files} folder(s) + {:else} + {i18nMap[folder as keyof typeof i18nMap]} is missing files! + {/if} + + {#if !files && (folder === 'profile' || folder === 'upload')} + You may be missing files + {/if} + {#if !files && (folder === 'encoded-video' || folder === 'thumbs')} + You can regenerate these later in settings + {/if} + {#if !files && folder === 'library'} + Using storage template? You may be missing files + {/if} + + + {/if} + {/each} + + + {:else} + + + Loading integrity checks and heuristics... + + {/if} + + + + If this looks correct, continue to restoring a backup! + + + + +{:else} + Restore From Backup + + + + + + + +{/if} diff --git a/web/src/routes/maintenance/+page.svelte b/web/src/routes/maintenance/+page.svelte index 72fe1f0d9f..a3fbdc696e 100644 --- a/web/src/routes/maintenance/+page.svelte +++ b/web/src/routes/maintenance/+page.svelte @@ -1,6 +1,6 @@ - +
{#if $status?.action === MaintenanceAction.RestoreDatabase && $status.task} Restoring Database @@ -57,11 +60,7 @@ {/if} {/if} {:else if $status?.action === MaintenanceAction.RestoreDatabase && $auth} - Restore From Backup - - - - + {:else} {$t('maintenance_title')}