From 9b955508e91fe22337316185843320738d2f9218 Mon Sep 17 00:00:00 2001 From: izzy Date: Tue, 2 Dec 2025 17:59:21 +0000 Subject: [PATCH] refactor: split into database backup controller --- e2e/src/api/specs/maintenance.e2e-spec.ts | 12 +- e2e/src/utils.ts | 2 +- mobile/openapi/README.md | 10 +- mobile/openapi/lib/api.dart | 1 + .../lib/api/database_backups_admin_api.dart | 270 ++++++++++++++++++ .../lib/api/maintenance_admin_api.dart | 252 ---------------- .../maintenance_status_response_dto.dart | 10 +- open-api/immich-openapi-specs.json | 128 +++++---- open-api/typescript-sdk/src/fetch-client.ts | 43 +-- server/src/constants.ts | 1 + .../controllers/database-backup.controller.ts | 97 +++++++ server/src/controllers/index.ts | 2 + .../src/controllers/maintenance.controller.ts | 103 +------ server/src/enum.ts | 1 + .../maintenance-worker.controller.ts | 34 ++- .../maintenance-worker.service.spec.ts | 15 +- .../maintenance/maintenance-worker.service.ts | 108 ++++--- .../src/services/database-backup.service.ts | 50 ++++ server/src/services/index.ts | 2 + server/src/services/maintenance.service.ts | 49 +--- .../maintenance/MaintenanceBackupsList.svelte | 4 +- 21 files changed, 644 insertions(+), 550 deletions(-) create mode 100644 mobile/openapi/lib/api/database_backups_admin_api.dart create mode 100644 server/src/controllers/database-backup.controller.ts create mode 100644 server/src/services/database-backup.service.ts diff --git a/e2e/src/api/specs/maintenance.e2e-spec.ts b/e2e/src/api/specs/maintenance.e2e-spec.ts index cf0f467be6..6b2617fcda 100644 --- a/e2e/src/api/specs/maintenance.e2e-spec.ts +++ b/e2e/src/api/specs/maintenance.e2e-spec.ts @@ -48,7 +48,7 @@ describe('/admin/maintenance', () => { describe('GET /backups/list', async () => { it('should succeed and be empty', async () => { const { status, body } = await request(app) - .get('/admin/maintenance/backups/list') + .get('/admin/database-backups/list') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toEqual({ @@ -65,7 +65,7 @@ describe('/admin/maintenance', () => { .poll( async () => { const { status, body } = await request(app) - .get('/admin/maintenance/backups/list') + .get('/admin/database-backups/list') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -89,13 +89,13 @@ describe('/admin/maintenance', () => { const filename = await utils.createBackup(admin.accessToken); const { status } = await request(app) - .delete(`/admin/maintenance/backups/${filename}`) + .delete(`/admin/database-backups/${filename}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); const { status: listStatus, body } = await request(app) - .get('/admin/maintenance/backups/list') + .get('/admin/database-backups/list') .set('Authorization', `Bearer ${admin.accessToken}`); expect(listStatus).toBe(200); @@ -271,7 +271,7 @@ describe('/admin/maintenance', () => { }); it.sequential('should not work when the server is configured', async () => { - const { status, body } = await request(app).post('/admin/maintenance/backups/restore').send(); + const { status, body } = await request(app).post('/admin/database-backups/restore').send(); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('The server already has an admin')); @@ -280,7 +280,7 @@ describe('/admin/maintenance', () => { it.sequential('should enter maintenance mode in "database restore mode"', async () => { await utils.resetDatabase(); // reset database before running this test - const { status, headers } = await request(app).post('/admin/maintenance/backups/restore').send(); + const { status, headers } = await request(app).post('/admin/database-backups/restore').send(); expect(status).toBe(201); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 589af4e90f..ffcc3fcf31 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -592,7 +592,7 @@ export const utils = { }); return await utils.poll( - () => request(app).get('/admin/maintenance/backups/list').set('Authorization', `Bearer ${accessToken}`), + () => request(app).get('/admin/database-backups/list').set('Authorization', `Bearer ${accessToken}`), ({ status, body }) => status === 200 && body.backups.length === 1, ({ body }) => body.backups[0], ); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7925df0e99..0cc686df3b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -133,6 +133,11 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | Unlock auth session *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | Validate access token *AuthenticationAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthenticationAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | Unlink all OAuth accounts +*DatabaseBackupsAdminApi* | [**deleteBackup**](doc//DatabaseBackupsAdminApi.md#deletebackup) | **DELETE** /admin/database-backups/{filename} | Delete backup +*DatabaseBackupsAdminApi* | [**downloadBackup**](doc//DatabaseBackupsAdminApi.md#downloadbackup) | **GET** /admin/database-backups/{filename} | Download backup +*DatabaseBackupsAdminApi* | [**listBackups**](doc//DatabaseBackupsAdminApi.md#listbackups) | **GET** /admin/database-backups | List backups +*DatabaseBackupsAdminApi* | [**startRestoreFlow**](doc//DatabaseBackupsAdminApi.md#startrestoreflow) | **POST** /admin/database-backups/start-restore | Start backup restore flow +*DatabaseBackupsAdminApi* | [**uploadBackup**](doc//DatabaseBackupsAdminApi.md#uploadbackup) | **POST** /admin/database-backups/upload | Upload database backup *DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner *DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID *DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user @@ -161,15 +166,10 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library *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* | [**detectPriorInstall**](doc//MaintenanceAdminApi.md#detectpriorinstall) | **GET** /admin/maintenance/detect-install | Detect existing install -*MaintenanceAdminApi* | [**downloadBackup**](doc//MaintenanceAdminApi.md#downloadbackup) | **GET** /admin/maintenance/backups/{filename} | Download backup *MaintenanceAdminApi* | [**getMaintenanceStatus**](doc//MaintenanceAdminApi.md#getmaintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status -*MaintenanceAdminApi* | [**listBackups**](doc//MaintenanceAdminApi.md#listbackups) | **GET** /admin/maintenance/backups | List backups *MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode *MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode -*MaintenanceAdminApi* | [**startRestoreFlow**](doc//MaintenanceAdminApi.md#startrestoreflow) | **POST** /admin/maintenance/backups/restore | Start backup restore flow -*MaintenanceAdminApi* | [**uploadBackup**](doc//MaintenanceAdminApi.md#uploadbackup) | **POST** /admin/maintenance/backups/upload | Upload database backup *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers *MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ffac39b8fb..199ddd12e7 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -36,6 +36,7 @@ part 'api/albums_api.dart'; part 'api/assets_api.dart'; part 'api/authentication_api.dart'; part 'api/authentication_admin_api.dart'; +part 'api/database_backups_admin_api.dart'; part 'api/deprecated_api.dart'; part 'api/download_api.dart'; part 'api/duplicates_api.dart'; diff --git a/mobile/openapi/lib/api/database_backups_admin_api.dart b/mobile/openapi/lib/api/database_backups_admin_api.dart new file mode 100644 index 0000000000..4f2889d0e7 --- /dev/null +++ b/mobile/openapi/lib/api/database_backups_admin_api.dart @@ -0,0 +1,270 @@ +// +// 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 DatabaseBackupsAdminApi { + DatabaseBackupsAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Delete backup + /// + /// Delete a backup by its filename + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] filename (required): + Future deleteBackupWithHttpInfo(String filename,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/database-backups/{filename}' + .replaceAll('{filename}', filename); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete backup + /// + /// Delete a backup by its filename + /// + /// Parameters: + /// + /// * [String] filename (required): + Future deleteBackup(String filename,) async { + final response = await deleteBackupWithHttpInfo(filename,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// 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/database-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; + } + + /// List backups + /// + /// Get the list of the successful and failed backups + /// + /// Note: This method returns the HTTP [Response]. + Future listBackupsWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/database-backups'; + + // 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, + ); + } + + /// List backups + /// + /// Get the list of the successful and failed backups + Future listBackups() async { + final response = await listBackupsWithHttpInfo(); + 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), 'MaintenanceListBackupsResponseDto',) as MaintenanceListBackupsResponseDto; + + } + return null; + } + + /// Start backup restore flow + /// + /// Put Immich into maintenance mode to restore a backup (Immich must not be configured) + /// + /// Note: This method returns the HTTP [Response]. + Future startRestoreFlowWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/database-backups/start-restore'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Start backup restore flow + /// + /// Put Immich into maintenance mode to restore a backup (Immich must not be configured) + Future startRestoreFlow() async { + final response = await startRestoreFlowWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Upload database backup + /// + /// Uploads .sql/.sql.gz file to restore backup from + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [MultipartFile] file: + Future uploadBackupWithHttpInfo({ MultipartFile? file, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/database-backups/upload'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['multipart/form-data']; + + bool hasFields = false; + final mp = MultipartRequest('POST', Uri.parse(apiPath)); + if (file != null) { + hasFields = true; + mp.fields[r'file'] = file.field; + mp.files.add(file); + } + if (hasFields) { + postBody = mp; + } + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Upload database backup + /// + /// Uploads .sql/.sql.gz file to restore backup from + /// + /// Parameters: + /// + /// * [MultipartFile] file: + Future uploadBackup({ MultipartFile? file, }) async { + final response = await uploadBackupWithHttpInfo( file: file, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/mobile/openapi/lib/api/maintenance_admin_api.dart b/mobile/openapi/lib/api/maintenance_admin_api.dart index 2c63f90d79..dce1aa1dac 100644 --- a/mobile/openapi/lib/api/maintenance_admin_api.dart +++ b/mobile/openapi/lib/api/maintenance_admin_api.dart @@ -16,55 +16,6 @@ class MaintenanceAdminApi { final ApiClient apiClient; - /// Delete backup - /// - /// Delete a backup by its filename - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [String] filename (required): - Future deleteBackupWithHttpInfo(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, - 'DELETE', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Delete backup - /// - /// Delete a backup by its filename - /// - /// Parameters: - /// - /// * [String] filename (required): - Future deleteBackup(String filename,) async { - final response = await deleteBackupWithHttpInfo(filename,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - /// Detect existing install /// /// Collect integrity checks and other heuristics about local data. @@ -113,63 +64,6 @@ class MaintenanceAdminApi { return null; } - /// 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 maintenance mode status /// /// Fetch information about the currently running maintenance action. @@ -218,54 +112,6 @@ class MaintenanceAdminApi { return null; } - /// List backups - /// - /// Get the list of the successful and failed backups - /// - /// Note: This method returns the HTTP [Response]. - Future listBackupsWithHttpInfo() async { - // ignore: prefer_const_declarations - final apiPath = r'/admin/maintenance/backups'; - - // 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, - ); - } - - /// List backups - /// - /// Get the list of the successful and failed backups - Future listBackups() async { - final response = await listBackupsWithHttpInfo(); - 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), 'MaintenanceListBackupsResponseDto',) as MaintenanceListBackupsResponseDto; - - } - return null; - } - /// Log into maintenance mode /// /// Login with maintenance token or cookie to receive current information and perform further actions. @@ -369,102 +215,4 @@ class MaintenanceAdminApi { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } } - - /// Start backup restore flow - /// - /// Put Immich into maintenance mode to restore a backup (Immich must not be configured) - /// - /// Note: This method returns the HTTP [Response]. - Future startRestoreFlowWithHttpInfo() async { - // ignore: prefer_const_declarations - final apiPath = r'/admin/maintenance/backups/restore'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - apiPath, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Start backup restore flow - /// - /// Put Immich into maintenance mode to restore a backup (Immich must not be configured) - Future startRestoreFlow() async { - final response = await startRestoreFlowWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - - /// Upload database backup - /// - /// Uploads .sql/.sql.gz file to restore backup from - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [MultipartFile] file: - Future uploadBackupWithHttpInfo({ MultipartFile? file, }) async { - // ignore: prefer_const_declarations - final apiPath = r'/admin/maintenance/backups/upload'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['multipart/form-data']; - - bool hasFields = false; - final mp = MultipartRequest('POST', Uri.parse(apiPath)); - if (file != null) { - hasFields = true; - mp.fields[r'file'] = file.field; - mp.files.add(file); - } - if (hasFields) { - postBody = mp; - } - - return apiClient.invokeAPI( - apiPath, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Upload database backup - /// - /// Uploads .sql/.sql.gz file to restore backup from - /// - /// Parameters: - /// - /// * [MultipartFile] file: - Future uploadBackup({ MultipartFile? file, }) async { - final response = await uploadBackupWithHttpInfo( file: file, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } } diff --git a/mobile/openapi/lib/model/maintenance_status_response_dto.dart b/mobile/openapi/lib/model/maintenance_status_response_dto.dart index aa93b27b13..124fa674fd 100644 --- a/mobile/openapi/lib/model/maintenance_status_response_dto.dart +++ b/mobile/openapi/lib/model/maintenance_status_response_dto.dart @@ -14,6 +14,7 @@ class MaintenanceStatusResponseDto { /// Returns a new [MaintenanceStatusResponseDto] instance. MaintenanceStatusResponseDto({ required this.action, + required this.active, this.error, this.progress, this.task, @@ -21,6 +22,8 @@ class MaintenanceStatusResponseDto { MaintenanceAction action; + bool active; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -48,6 +51,7 @@ class MaintenanceStatusResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is MaintenanceStatusResponseDto && other.action == action && + other.active == active && other.error == error && other.progress == progress && other.task == task; @@ -56,16 +60,18 @@ class MaintenanceStatusResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (action.hashCode) + + (active.hashCode) + (error == null ? 0 : error!.hashCode) + (progress == null ? 0 : progress!.hashCode) + (task == null ? 0 : task!.hashCode); @override - String toString() => 'MaintenanceStatusResponseDto[action=$action, error=$error, progress=$progress, task=$task]'; + String toString() => 'MaintenanceStatusResponseDto[action=$action, active=$active, error=$error, progress=$progress, task=$task]'; Map toJson() { final json = {}; json[r'action'] = this.action; + json[r'active'] = this.active; if (this.error != null) { json[r'error'] = this.error; } else { @@ -94,6 +100,7 @@ class MaintenanceStatusResponseDto { return MaintenanceStatusResponseDto( action: MaintenanceAction.fromJson(json[r'action'])!, + active: mapValueOfType(json, r'active')!, error: mapValueOfType(json, r'error'), progress: num.parse('${json[r'progress']}'), task: mapValueOfType(json, r'task'), @@ -145,6 +152,7 @@ class MaintenanceStatusResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'action', + 'active', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 90935706e3..e20de055ad 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -322,57 +322,7 @@ "x-immich-state": "Stable" } }, - "/admin/maintenance": { - "post": { - "description": "Put Immich into or take it out of maintenance mode", - "operationId": "setMaintenanceMode", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetMaintenanceModeDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "summary": "Set maintenance mode", - "tags": [ - "Maintenance (admin)" - ], - "x-immich-admin-only": true, - "x-immich-history": [ - { - "version": "v2.3.0", - "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" - } - ], - "x-immich-permission": "maintenance", - "x-immich-state": "Alpha" - } - }, - "/admin/maintenance/backups": { + "/admin/database-backups": { "get": { "description": "Get the list of the successful and failed backups", "operationId": "listBackups", @@ -402,7 +352,7 @@ ], "summary": "List backups", "tags": [ - "Maintenance (admin)" + "Database Backups (admin)" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -419,7 +369,7 @@ "x-immich-state": "Alpha" } }, - "/admin/maintenance/backups/restore": { + "/admin/database-backups/start-restore": { "post": { "description": "Put Immich into maintenance mode to restore a backup (Immich must not be configured)", "operationId": "startRestoreFlow", @@ -431,7 +381,7 @@ }, "summary": "Start backup restore flow", "tags": [ - "Maintenance (admin)" + "Database Backups (admin)" ], "x-immich-history": [ { @@ -446,7 +396,7 @@ "x-immich-state": "Alpha" } }, - "/admin/maintenance/backups/upload": { + "/admin/database-backups/upload": { "post": { "description": "Uploads .sql/.sql.gz file to restore backup from", "operationId": "uploadBackup", @@ -480,7 +430,7 @@ ], "summary": "Upload database backup", "tags": [ - "Maintenance (admin)" + "Database Backups (admin)" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -497,7 +447,7 @@ "x-immich-state": "Alpha" } }, - "/admin/maintenance/backups/{filename}": { + "/admin/database-backups/{filename}": { "delete": { "description": "Delete a backup by its filename", "operationId": "deleteBackup", @@ -530,7 +480,7 @@ ], "summary": "Delete backup", "tags": [ - "Maintenance (admin)" + "Database Backups (admin)" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -586,7 +536,7 @@ ], "summary": "Download backup", "tags": [ - "Maintenance (admin)" + "Database Backups (admin)" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -603,6 +553,56 @@ "x-immich-state": "Alpha" } }, + "/admin/maintenance": { + "post": { + "description": "Put Immich into or take it out of maintenance mode", + "operationId": "setMaintenanceMode", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetMaintenanceModeDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Set maintenance mode", + "tags": [ + "Maintenance (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "maintenance", + "x-immich-state": "Alpha" + } + }, "/admin/maintenance/detect-install": { "get": { "description": "Collect integrity checks and other heuristics about local data.", @@ -14595,6 +14595,10 @@ "name": "Authentication (admin)", "description": "Administrative endpoints related to authentication." }, + { + "name": "Database Backups (admin)", + "description": "Manage backups of the Immich database." + }, { "name": "Deprecated", "description": "Deprecated endpoints that are planned for removal in the next major release." @@ -17259,6 +17263,9 @@ } ] }, + "active": { + "type": "boolean" + }, "error": { "type": "string" }, @@ -17270,7 +17277,8 @@ } }, "required": [ - "action" + "action", + "active" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 81a15c4bc4..418255116c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -40,16 +40,16 @@ export type ActivityStatisticsResponseDto = { comments: number; likes: number; }; -export type SetMaintenanceModeDto = { - action: MaintenanceAction; - restoreBackupFilename?: string; -}; export type MaintenanceListBackupsResponseDto = { backups: string[]; }; export type MaintenanceUploadBackupDto = { file?: Blob; }; +export type SetMaintenanceModeDto = { + action: MaintenanceAction; + restoreBackupFilename?: string; +}; export type MaintenanceStorageFolderIntegrityDto = { files: number; folder: StorageFolder; @@ -67,6 +67,7 @@ export type MaintenanceAuthDto = { }; export type MaintenanceStatusResponseDto = { action: MaintenanceAction; + active: boolean; error?: string; progress?: number; task?: string; @@ -1872,18 +1873,6 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) { method: "POST" })); } -/** - * Set maintenance mode - */ -export function setMaintenanceMode({ setMaintenanceModeDto }: { - setMaintenanceModeDto: SetMaintenanceModeDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/admin/maintenance", oazapfts.json({ - ...opts, - method: "POST", - body: setMaintenanceModeDto - }))); -} /** * List backups */ @@ -1891,7 +1880,7 @@ export function listBackups(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: MaintenanceListBackupsResponseDto; - }>("/admin/maintenance/backups", { + }>("/admin/database-backups", { ...opts })); } @@ -1899,7 +1888,7 @@ export function listBackups(opts?: Oazapfts.RequestOpts) { * Start backup restore flow */ export function startRestoreFlow(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/admin/maintenance/backups/restore", { + return oazapfts.ok(oazapfts.fetchText("/admin/database-backups/start-restore", { ...opts, method: "POST" })); @@ -1910,7 +1899,7 @@ export function startRestoreFlow(opts?: Oazapfts.RequestOpts) { export function uploadBackup({ maintenanceUploadBackupDto }: { maintenanceUploadBackupDto: MaintenanceUploadBackupDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/admin/maintenance/backups/upload", oazapfts.multipart({ + return oazapfts.ok(oazapfts.fetchText("/admin/database-backups/upload", oazapfts.multipart({ ...opts, method: "POST", body: maintenanceUploadBackupDto @@ -1922,7 +1911,7 @@ export function uploadBackup({ maintenanceUploadBackupDto }: { export function deleteBackup({ filename }: { filename: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/admin/maintenance/backups/${encodeURIComponent(filename)}`, { + return oazapfts.ok(oazapfts.fetchText(`/admin/database-backups/${encodeURIComponent(filename)}`, { ...opts, method: "DELETE" })); @@ -1936,10 +1925,22 @@ export function downloadBackup({ filename }: { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; - }>(`/admin/maintenance/backups/${encodeURIComponent(filename)}`, { + }>(`/admin/database-backups/${encodeURIComponent(filename)}`, { ...opts })); } +/** + * Set maintenance mode + */ +export function setMaintenanceMode({ setMaintenanceModeDto }: { + setMaintenanceModeDto: SetMaintenanceModeDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/admin/maintenance", oazapfts.json({ + ...opts, + method: "POST", + body: setMaintenanceModeDto + }))); +} /** * Detect existing install */ diff --git a/server/src/constants.ts b/server/src/constants.ts index 33f8e3b4c5..169eb8d527 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -141,6 +141,7 @@ export const endpointTags: Record = { [ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', + [ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.', [ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.', [ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.', [ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.', diff --git a/server/src/controllers/database-backup.controller.ts b/server/src/controllers/database-backup.controller.ts new file mode 100644 index 0000000000..5cbe212d6b --- /dev/null +++ b/server/src/controllers/database-backup.controller.ts @@ -0,0 +1,97 @@ +import { Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { MaintenanceListBackupsResponseDto, MaintenanceUploadBackupDto } from 'src/dtos/maintenance.dto'; +import { ApiTag, ImmichCookie, Permission } from 'src/enum'; +import { Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { LoginDetails } from 'src/services/auth.service'; +import { DatabaseBackupService } from 'src/services/database-backup.service'; +import { MaintenanceService } from 'src/services/maintenance.service'; +import { sendFile } from 'src/utils/file'; +import { respondWithCookie } from 'src/utils/response'; +import { FilenameParamDto } from 'src/validation'; + +@ApiTags(ApiTag.DatabaseBackups) +@Controller('admin/database-backups') +export class DatabaseBackupController { + constructor( + private logger: LoggingRepository, + private service: DatabaseBackupService, + private maintenanceService: MaintenanceService, + ) {} + + @Get() + @Endpoint({ + summary: 'List backups', + description: 'Get the list of the successful and failed backups', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + @Authenticated({ permission: Permission.Maintenance, admin: true }) + listBackups(): Promise { + return this.service.listBackups(); + } + + @Get(':filename') + @FileResponse() + @Endpoint({ + summary: 'Download backup', + description: 'Downloads the database backup file', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + @Authenticated({ permission: Permission.BackupDownload, admin: true }) + async downloadBackup( + @Param() { filename }: FilenameParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ): Promise { + await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger); + } + + @Delete(':filename') + @Endpoint({ + summary: 'Delete backup', + description: 'Delete a backup by its filename', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + @Authenticated({ permission: Permission.BackupDelete, admin: true }) + async deleteBackup(@Param() { filename }: FilenameParamDto): Promise { + return this.service.deleteBackup(filename); + } + + @Post('start-restore') + @Endpoint({ + summary: 'Start backup restore flow', + description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + async startRestoreFlow( + @GetLoginDetails() loginDetails: LoginDetails, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { jwt } = await this.maintenanceService.startRestoreFlow(); + return respondWithCookie(res, undefined, { + isSecure: loginDetails.isSecure, + values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }], + }); + } + + @Post('upload') + @Authenticated({ permission: Permission.BackupUpload, admin: true }) + @ApiConsumes('multipart/form-data') + @ApiBody({ description: 'Backup Upload', type: MaintenanceUploadBackupDto }) + @Endpoint({ + summary: 'Upload database backup', + description: 'Uploads .sql/.sql.gz file to restore backup from', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + @UseInterceptors(FileInterceptor('file')) + uploadBackup( + @UploadedFile() + file: Express.Multer.File, + ): Promise { + return this.service.uploadBackup(file); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 6ba3d38a73..dc3754ce24 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -6,6 +6,7 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller'; import { AuthController } from 'src/controllers/auth.controller'; +import { DatabaseBackupController } from 'src/controllers/database-backup.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; import { FaceController } from 'src/controllers/face.controller'; @@ -46,6 +47,7 @@ export const controllers = [ AssetMediaController, AuthController, AuthAdminController, + DatabaseBackupController, DownloadController, DuplicateController, FaceController, diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index df0db1613c..a6b5e21787 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -1,46 +1,25 @@ -import { - BadRequestException, - Body, - Controller, - Delete, - Get, - Next, - Param, - Post, - Res, - UploadedFile, - UseInterceptors, -} from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; -import { NextFunction, Response } from 'express'; +import { BadRequestException, Body, Controller, Get, Post, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { MaintenanceAuthDto, MaintenanceIntegrityResponseDto, - MaintenanceListBackupsResponseDto, MaintenanceLoginDto, MaintenanceStatusResponseDto, - MaintenanceUploadBackupDto, SetMaintenanceModeDto, } from 'src/dtos/maintenance.dto'; import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum'; -import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard'; -import { LoggingRepository } from 'src/repositories/logging.repository'; +import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { LoginDetails } from 'src/services/auth.service'; import { MaintenanceService } from 'src/services/maintenance.service'; -import { sendFile } from 'src/utils/file'; import { respondWithCookie } from 'src/utils/response'; -import { FilenameParamDto } from 'src/validation'; @ApiTags(ApiTag.Maintenance) @Controller('admin/maintenance') export class MaintenanceController { - constructor( - private logger: LoggingRepository, - private service: MaintenanceService, - ) {} + constructor(private service: MaintenanceService) {} @Get('status') @Endpoint({ @@ -93,76 +72,4 @@ export class MaintenanceController { }); } } - - @Get('backups') - @Endpoint({ - summary: 'List backups', - description: 'Get the list of the successful and failed backups', - history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), - }) - @Authenticated({ permission: Permission.Maintenance, admin: true }) - listBackups(): Promise { - return this.service.listBackups(); - } - - @Get('backups/:filename') - @FileResponse() - @Endpoint({ - summary: 'Download backup', - description: 'Downloads the database backup file', - history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), - }) - @Authenticated({ permission: Permission.BackupDownload, admin: true }) - async downloadBackup( - @Param() { filename }: FilenameParamDto, - @Res() res: Response, - @Next() next: NextFunction, - ): Promise { - await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger); - } - - @Delete('backups/:filename') - @Endpoint({ - summary: 'Delete backup', - description: 'Delete a backup by its filename', - history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), - }) - @Authenticated({ permission: Permission.BackupDelete, admin: true }) - async deleteBackup(@Param() { filename }: FilenameParamDto): Promise { - return this.service.deleteBackup(filename); - } - - @Post('backups/restore') - @Endpoint({ - summary: 'Start backup restore flow', - description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)', - history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), - }) - async startRestoreFlow( - @GetLoginDetails() loginDetails: LoginDetails, - @Res({ passthrough: true }) res: Response, - ): Promise { - const { jwt } = await this.service.startRestoreFlow(); - return respondWithCookie(res, undefined, { - isSecure: loginDetails.isSecure, - values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }], - }); - } - - @Post('backups/upload') - @Authenticated({ permission: Permission.BackupUpload, admin: true }) - @ApiConsumes('multipart/form-data') - @ApiBody({ description: 'Backup Upload', type: MaintenanceUploadBackupDto }) - @Endpoint({ - summary: 'Upload database backup', - description: 'Uploads .sql/.sql.gz file to restore backup from', - history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), - }) - @UseInterceptors(FileInterceptor('file')) - uploadBackup( - @UploadedFile() - file: Express.Multer.File, - ): Promise { - return this.service.uploadBackup(file); - } } diff --git a/server/src/enum.ts b/server/src/enum.ts index 71f4442913..18206ea1ae 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -837,6 +837,7 @@ export enum ApiTag { Authentication = 'Authentication', AuthenticationAdmin = 'Authentication (admin)', Assets = 'Assets', + DatabaseBackups = 'Database Backups (admin)', Deprecated = 'Deprecated', Download = 'Download', Duplicates = 'Duplicates', diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts index 134c91411e..5aa9bb630f 100644 --- a/server/src/maintenance/maintenance-worker.controller.ts +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -1,6 +1,18 @@ -import { Body, Controller, Delete, Get, Param, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Next, + Param, + Post, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { Request, Response } from 'express'; +import { NextFunction, Request, Response } from 'express'; import { MaintenanceAuthDto, MaintenanceIntegrityResponseDto, @@ -14,16 +26,17 @@ 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 { LoggingRepository } from 'src/repositories/logging.repository'; import { LoginDetails } from 'src/services/auth.service'; +import { sendFile } from 'src/utils/file'; import { respondWithCookie } from 'src/utils/response'; import { FilenameParamDto } from 'src/validation'; @Controller() export class MaintenanceWorkerController { constructor( + private logger: LoggingRepository, private service: MaintenanceWorkerService, - private storageRepository: StorageRepository, ) {} @Get('server/config') @@ -62,26 +75,25 @@ export class MaintenanceWorkerController { void this.service.setAction(dto); } - @Get('admin/maintenance/backups/list') + @Get('admin/database-backups/list') @MaintenanceRoute() listBackups(): Promise { return this.service.listBackups(); } - @Get('admin/maintenance/backups/:filename') + @Get('admin/database-backups/:filename') @MaintenanceRoute() - downloadBackup(@Param() { filename }: FilenameParamDto, @Res() res: Response) { - res.header('Content-Disposition', 'attachment'); - res.sendFile(this.service.getBackupPath(filename)); + async downloadBackup(@Param() { filename }: FilenameParamDto, @Res() res: Response, @Next() next: NextFunction) { + await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger); } - @Delete('admin/maintenance/backups/:filename') + @Delete('admin/database-backups/:filename') @MaintenanceRoute() async deleteBackup(@Param() { filename }: FilenameParamDto): Promise { return this.service.deleteBackup(filename); } - @Post('admin/maintenance/backups/upload') + @Post('admin/database-backups/upload') @MaintenanceRoute() @UseInterceptors(FileInterceptor('file')) uploadBackup( diff --git a/server/src/maintenance/maintenance-worker.service.spec.ts b/server/src/maintenance/maintenance-worker.service.spec.ts index ababdb80f2..8297f0e4c8 100644 --- a/server/src/maintenance/maintenance-worker.service.spec.ts +++ b/server/src/maintenance/maintenance-worker.service.spec.ts @@ -354,6 +354,7 @@ describe(MaintenanceWorkerService.name, () => { }); expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenCalledWith({ + active: true, action: MaintenanceAction.RestoreDatabase, error: 'Error: Invalid backup file format!', task: 'error', @@ -367,12 +368,14 @@ describe(MaintenanceWorkerService.name, () => { }); expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenCalledWith({ + active: true, action: MaintenanceAction.RestoreDatabase, task: 'ready', progress: expect.any(Number), }); expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenLastCalledWith({ + active: true, action: 'end', }); }); @@ -386,6 +389,7 @@ describe(MaintenanceWorkerService.name, () => { }); expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenLastCalledWith({ + active: true, action: MaintenanceAction.RestoreDatabase, error: 'Error: pg_dump non-zero exit code (1)\nerror', task: 'error', @@ -404,6 +408,7 @@ describe(MaintenanceWorkerService.name, () => { }); expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenLastCalledWith({ + active: true, action: MaintenanceAction.RestoreDatabase, error: 'Error: psql non-zero exit code (1)\nerror', task: 'error', @@ -455,13 +460,17 @@ describe(MaintenanceWorkerService.name, () => { }); }); - describe('getBackupPath', () => { + describe('downloadBackup', () => { it('should reject invalid file names', () => { - expect(() => sut.getBackupPath('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!')); + expect(() => sut.downloadBackup('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'); + expect(sut.downloadBackup('hello.sql.gz')).toEqual( + expect.objectContaining({ + path: '/data/backups/hello.sql.gz', + }), + ); }); }); }); diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index a40c3d6d54..2607d612ac 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -13,7 +13,14 @@ import { SetMaintenanceModeDto, } from 'src/dtos/maintenance.dto'; import { ServerConfigDto } from 'src/dtos/server.dto'; -import { DatabaseLock, ImmichCookie, MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; +import { + CacheControl, + DatabaseLock, + ImmichCookie, + MaintenanceAction, + StorageFolder, + 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'; @@ -25,10 +32,12 @@ 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 DatabaseBackupService as _DatabaseBackupService } from 'src/services/database-backup.service'; import { type ServerService as _ServerService } from 'src/services/server.service'; import { MaintenanceModeState } from 'src/types'; import { deleteBackup, isValidBackupName, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups'; import { getConfig } from 'src/utils/config'; +import { ImmichFileResponse } from 'src/utils/file'; import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; @@ -160,6 +169,55 @@ export class MaintenanceWorkerService { return '/usr/src/app/upload'; } + /** + * {@link _DatabaseBackupService.listBackups} + */ + async listBackups(): Promise<{ backups: string[] }> { + return { backups: await listBackups(this.backupRepos) }; + } + + /** + * {@link _DatabaseBackupService.deleteBackup} + */ + async deleteBackup(filename: string): Promise { + return deleteBackup(this.backupRepos, filename); + } + + /** + * {@link _DatabaseBackupService.uploadBackup} + */ + async uploadBackup(file: Express.Multer.File): Promise { + return uploadBackup(this.backupRepos, file); + } + + /** + * {@link _DatabaseBackupService.downloadBackup} + */ + downloadBackup(fileName: string): ImmichFileResponse { + if (!isValidBackupName(fileName)) { + throw new BadRequestException('Invalid backup name!'); + } + + const path = join(StorageCore.getBaseFolder(StorageFolder.Backups), fileName); + + return { + path, + fileName, + cacheControl: CacheControl.PrivateWithoutCache, + contentType: fileName.endsWith('.gz') ? 'application/gzip' : 'application/sql', + }; + } + + private get backupRepos() { + return { + logger: this.logger, + storage: this.storageRepository, + config: this.configRepository, + process: this.processRepository, + database: this.databaseRepository, + }; + } + setStatus(status: MaintenanceStatusResponseDto): void { this.maintenanceEphemeralStateRepository.setStatus(status); this.maintenanceWebsocketRepository.serverSend('MaintenanceStatus', status); @@ -278,20 +336,6 @@ export class MaintenanceWorkerService { } } - private async endMaintenance(): Promise { - const state: MaintenanceModeState = { isMaintenanceMode: false as const }; - await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); - - // => corresponds to notification.service.ts#onAppRestart - this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state); - this.maintenanceWebsocketRepository.serverSend('AppRestart', state); - this.appRepository.exitApp(); - } - - /** - * Backups - */ - private async restoreBackup(filename: string): Promise { this.setStatus({ active: true, @@ -314,33 +358,13 @@ export class MaintenanceWorkerService { }); } - async listBackups(): Promise<{ backups: string[] }> { - return { backups: await listBackups(this.backupRepos) }; - } + private async endMaintenance(): Promise { + const state: MaintenanceModeState = { isMaintenanceMode: false as const }; + await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); - async deleteBackup(filename: string): Promise { - return deleteBackup(this.backupRepos, filename); - } - - async uploadBackup(file: Express.Multer.File): Promise { - return uploadBackup(this.backupRepos, file); - } - - getBackupPath(filename: string): string { - if (!isValidBackupName(filename)) { - throw new BadRequestException('Invalid backup name!'); - } - - return join(StorageCore.getBaseFolder(StorageFolder.Backups), filename); - } - - private get backupRepos() { - return { - logger: this.logger, - storage: this.storageRepository, - config: this.configRepository, - process: this.processRepository, - database: this.databaseRepository, - }; + // => corresponds to notification.service.ts#onAppRestart + this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state); + this.maintenanceWebsocketRepository.serverSend('AppRestart', state); + this.appRepository.exitApp(); } } diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts new file mode 100644 index 0000000000..0e1bda9249 --- /dev/null +++ b/server/src/services/database-backup.service.ts @@ -0,0 +1,50 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { basename, join } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; +import { CacheControl, StorageFolder } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; +import { deleteBackup, isValidBackupName, listBackups, uploadBackup } from 'src/utils/backups'; +import { ImmichFileResponse } from 'src/utils/file'; + +/** + * This service is available outside of maintenance mode to manage maintenance mode + */ +@Injectable() +export class DatabaseBackupService extends BaseService { + async listBackups(): Promise<{ backups: string[] }> { + return { backups: await listBackups(this.backupRepos) }; + } + + async deleteBackup(filename: string): Promise { + return deleteBackup(this.backupRepos, basename(filename)); + } + + async uploadBackup(file: Express.Multer.File): Promise { + return uploadBackup(this.backupRepos, file); + } + + downloadBackup(fileName: string): ImmichFileResponse { + if (!isValidBackupName(fileName)) { + throw new BadRequestException('Invalid backup name!'); + } + + const path = join(StorageCore.getBaseFolder(StorageFolder.Backups), fileName); + + return { + path, + fileName, + cacheControl: CacheControl.PrivateWithoutCache, + contentType: fileName.endsWith('.gz') ? 'application/gzip' : 'application/sql', + }; + } + + 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/index.ts b/server/src/services/index.ts index eeb8424048..2c2fb995c8 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -9,6 +9,7 @@ import { AuthAdminService } from 'src/services/auth-admin.service'; import { AuthService } from 'src/services/auth.service'; import { BackupService } from 'src/services/backup.service'; import { CliService } from 'src/services/cli.service'; +import { DatabaseBackupService } from 'src/services/database-backup.service'; import { DatabaseService } from 'src/services/database.service'; import { DownloadService } from 'src/services/download.service'; import { DuplicateService } from 'src/services/duplicate.service'; @@ -59,6 +60,7 @@ export const services = [ AuthAdminService, BackupService, CliService, + DatabaseBackupService, DatabaseService, DownloadService, DuplicateService, diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index aa52deae87..92ab27392c 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -1,6 +1,4 @@ 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, @@ -8,11 +6,9 @@ import { MaintenanceStatusResponseDto, SetMaintenanceModeDto, } from 'src/dtos/maintenance.dto'; -import { CacheControl, MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; +import { MaintenanceAction, 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 { ImmichFileResponse } from 'src/utils/file'; import { createMaintenanceLoginUrl, detectPriorInstall, @@ -94,47 +90,4 @@ export class MaintenanceService extends BaseService { return await createMaintenanceLoginUrl(baseUrl, auth, secret); } - - /** - * Backups - */ - - async listBackups(): Promise<{ backups: string[] }> { - return { backups: await listBackups(this.backupRepos) }; - } - - async deleteBackup(filename: string): Promise { - return deleteBackup(this.backupRepos, basename(filename)); - } - - async uploadBackup(file: Express.Multer.File): Promise { - return uploadBackup(this.backupRepos, file); - } - - downloadBackup(fileName: string): ImmichFileResponse { - return { - fileName, - cacheControl: CacheControl.PrivateWithoutCache, - contentType: fileName.endsWith('.gz') ? 'application/gzip' : 'application/sql', - path: this.getBackupPath(fileName), - }; - } - - getBackupPath(filename: string): string { - if (!isValidBackupName(filename)) { - throw new BadRequestException('Invalid backup name!'); - } - - return join(StorageCore.getBaseFolder(StorageFolder.Backups), basename(filename)); - } - - private get backupRepos() { - return { - logger: this.logger, - storage: this.storageRepository, - config: this.configRepository, - process: this.processRepository, - database: this.databaseRepository, - }; - } } diff --git a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte index 879be23c5e..16be1296c0 100644 --- a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte +++ b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte @@ -106,7 +106,7 @@ } function download(filename: string) { - location.href = getBaseUrl() + '/admin/maintenance/backups/' + filename; + location.href = getBaseUrl() + '/admin/database-backups/' + filename; } const handleOpen = async (event: Event, props: Partial, filename: string) => { @@ -142,7 +142,7 @@ formData.append('file', file); await uploadRequest({ - url: getBaseUrl() + '/admin/maintenance/backups/upload', + url: getBaseUrl() + '/admin/database-backups/upload', data: formData, onUploadProgress(event) { uploadProgress = event.loaded / event.total;