refactor: split into database backup controller

This commit is contained in:
izzy
2025-12-02 17:59:21 +00:00
parent a79b4bdc47
commit 9b955508e9
21 changed files with 644 additions and 550 deletions

View File

@@ -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);

View File

@@ -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],
);

View File

@@ -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

View File

@@ -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';

View File

@@ -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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<void> 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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<MultipartFile?> 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<Response> listBackupsWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/database-backups';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<MaintenanceListBackupsResponseDto?> 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<Response> startRestoreFlowWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/database-backups/start-restore';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<void> 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<Response> uploadBackupWithHttpInfo({ MultipartFile? file, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/database-backups/upload';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['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<void> uploadBackup({ MultipartFile? file, }) async {
final response = await uploadBackupWithHttpInfo( file: file, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@@ -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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<void> 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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<MultipartFile?> 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<Response> listBackupsWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/backups';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<MaintenanceListBackupsResponseDto?> 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<Response> startRestoreFlowWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/backups/restore';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<void> 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<Response> uploadBackupWithHttpInfo({ MultipartFile? file, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/backups/upload';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['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<void> uploadBackup({ MultipartFile? file, }) async {
final response = await uploadBackupWithHttpInfo( file: file, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'),
progress: num.parse('${json[r'progress']}'),
task: mapValueOfType<String>(json, r'task'),
@@ -145,6 +152,7 @@ class MaintenanceStatusResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'active',
};
}

View File

@@ -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"
},

View File

@@ -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
*/

View File

@@ -141,6 +141,7 @@ export const endpointTags: Record<ApiTag, string> = {
[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.',

View File

@@ -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<MaintenanceListBackupsResponseDto> {
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<void> {
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<void> {
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<void> {
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<void> {
return this.service.uploadBackup(file);
}
}

View File

@@ -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,

View File

@@ -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<MaintenanceListBackupsResponseDto> {
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<void> {
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<void> {
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<void> {
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<void> {
return this.service.uploadBackup(file);
}
}

View File

@@ -837,6 +837,7 @@ export enum ApiTag {
Authentication = 'Authentication',
AuthenticationAdmin = 'Authentication (admin)',
Assets = 'Assets',
DatabaseBackups = 'Database Backups (admin)',
Deprecated = 'Deprecated',
Download = 'Download',
Duplicates = 'Duplicates',

View File

@@ -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<MaintenanceListBackupsResponseDto> {
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<void> {
return this.service.deleteBackup(filename);
}
@Post('admin/maintenance/backups/upload')
@Post('admin/database-backups/upload')
@MaintenanceRoute()
@UseInterceptors(FileInterceptor('file'))
uploadBackup(

View File

@@ -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',
}),
);
});
});
});

View File

@@ -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<void> {
return deleteBackup(this.backupRepos, filename);
}
/**
* {@link _DatabaseBackupService.uploadBackup}
*/
async uploadBackup(file: Express.Multer.File): Promise<void> {
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<void> {
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<void> {
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<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
async deleteBackup(filename: string): Promise<void> {
return deleteBackup(this.backupRepos, filename);
}
async uploadBackup(file: Express.Multer.File): Promise<void> {
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();
}
}

View File

@@ -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<void> {
return deleteBackup(this.backupRepos, basename(filename));
}
async uploadBackup(file: Express.Multer.File): Promise<void> {
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,
};
}
}

View File

@@ -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,

View File

@@ -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<void> {
return deleteBackup(this.backupRepos, basename(filename));
}
async uploadBackup(file: Express.Multer.File): Promise<void> {
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,
};
}
}

View File

@@ -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<ContextMenuBaseProps>, filename: string) => {
@@ -142,7 +142,7 @@
formData.append('file', file);
await uploadRequest<MaintenanceUploadBackupDto>({
url: getBaseUrl() + '/admin/maintenance/backups/upload',
url: getBaseUrl() + '/admin/database-backups/upload',
data: formData,
onUploadProgress(event) {
uploadProgress = event.loaded / event.total;