refactor: maintenanceStatus -> getMaintenanceStatus

refactor: `integrityCheck` -> `detectPriorInstall`
chore: add `v2.4.0` version
refactor: `/backups/list` -> `/backups`
refactor: use sendFile in download route
refactor: use separate backups permissions
chore: correct descriptions
refactor: permit handler that doesn't return promise for sendfile
This commit is contained in:
izzy
2025-12-02 16:47:31 +00:00
parent b5ff460a55
commit 94af1bba4d
16 changed files with 179 additions and 137 deletions

View File

@@ -162,14 +162,14 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update 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 *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* | [**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* | [**downloadBackup**](doc//MaintenanceAdminApi.md#downloadbackup) | **GET** /admin/maintenance/backups/{filename} | Download backup
*MaintenanceAdminApi* | [**integrityCheck**](doc//MaintenanceAdminApi.md#integritycheck) | **GET** /admin/maintenance/integrity | Get integrity and heuristics *MaintenanceAdminApi* | [**getMaintenanceStatus**](doc//MaintenanceAdminApi.md#getmaintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
*MaintenanceAdminApi* | [**listBackups**](doc//MaintenanceAdminApi.md#listbackups) | **GET** /admin/maintenance/backups/list | List backups *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* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
*MaintenanceAdminApi* | [**maintenanceStatus**](doc//MaintenanceAdminApi.md#maintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set 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* | [**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 asset *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* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates *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 *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory

View File

@@ -65,6 +65,54 @@ class MaintenanceAdminApi {
} }
} }
/// Detect existing install
///
/// Collect integrity checks and other heuristics about local data.
///
/// Note: This method returns the HTTP [Response].
Future<Response> detectPriorInstallWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/detect-install';
// 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,
);
}
/// Detect existing install
///
/// Collect integrity checks and other heuristics about local data.
Future<MaintenanceIntegrityResponseDto?> detectPriorInstall() async {
final response = await detectPriorInstallWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceIntegrityResponseDto',) as MaintenanceIntegrityResponseDto;
}
return null;
}
/// Download backup /// Download backup
/// ///
/// Downloads the database backup file /// Downloads the database backup file
@@ -122,14 +170,14 @@ class MaintenanceAdminApi {
return null; return null;
} }
/// Get integrity and heuristics /// Get maintenance mode status
/// ///
/// Collect integrity checks and other heuristics about local data. /// Fetch information about the currently running maintenance action.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
Future<Response> integrityCheckWithHttpInfo() async { Future<Response> getMaintenanceStatusWithHttpInfo() async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity'; final apiPath = r'/admin/maintenance/status';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody; Object? postBody;
@@ -152,11 +200,11 @@ class MaintenanceAdminApi {
); );
} }
/// Get integrity and heuristics /// Get maintenance mode status
/// ///
/// Collect integrity checks and other heuristics about local data. /// Fetch information about the currently running maintenance action.
Future<MaintenanceIntegrityResponseDto?> integrityCheck() async { Future<MaintenanceStatusResponseDto?> getMaintenanceStatus() async {
final response = await integrityCheckWithHttpInfo(); final response = await getMaintenanceStatusWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@@ -164,7 +212,7 @@ class MaintenanceAdminApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string. // FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceIntegrityResponseDto',) as MaintenanceIntegrityResponseDto; return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceStatusResponseDto',) as MaintenanceStatusResponseDto;
} }
return null; return null;
@@ -177,7 +225,7 @@ class MaintenanceAdminApi {
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
Future<Response> listBackupsWithHttpInfo() async { Future<Response> listBackupsWithHttpInfo() async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/backups/list'; final apiPath = r'/admin/maintenance/backups';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody; Object? postBody;
@@ -274,54 +322,6 @@ class MaintenanceAdminApi {
return null; return null;
} }
/// Get maintenance mode status
///
/// Fetch information about the currently running maintenance action.
///
/// Note: This method returns the HTTP [Response].
Future<Response> maintenanceStatusWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/status';
// 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,
);
}
/// Get maintenance mode status
///
/// Fetch information about the currently running maintenance action.
Future<MaintenanceStatusResponseDto?> maintenanceStatus() async {
final response = await maintenanceStatusWithHttpInfo();
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), 'MaintenanceStatusResponseDto',) as MaintenanceStatusResponseDto;
}
return null;
}
/// Set maintenance mode /// Set maintenance mode
/// ///
/// Put Immich into or take it out of maintenance mode /// Put Immich into or take it out of maintenance mode
@@ -410,9 +410,9 @@ class MaintenanceAdminApi {
} }
} }
/// Upload asset /// Upload database backup
/// ///
/// Uploads a new asset to the server. /// Uploads .sql/.sql.gz file to restore backup from
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
/// ///
@@ -454,9 +454,9 @@ class MaintenanceAdminApi {
); );
} }
/// Upload asset /// Upload database backup
/// ///
/// Uploads a new asset to the server. /// Uploads .sql/.sql.gz file to restore backup from
/// ///
/// Parameters: /// Parameters:
/// ///

View File

@@ -58,6 +58,10 @@ class Permission {
static const authPeriodChangePassword = Permission._(r'auth.changePassword'); static const authPeriodChangePassword = Permission._(r'auth.changePassword');
static const authDevicePeriodDelete = Permission._(r'authDevice.delete'); static const authDevicePeriodDelete = Permission._(r'authDevice.delete');
static const archivePeriodRead = Permission._(r'archive.read'); static const archivePeriodRead = Permission._(r'archive.read');
static const backupPeriodList = Permission._(r'backup.list');
static const backupPeriodDownload = Permission._(r'backup.download');
static const backupPeriodUpload = Permission._(r'backup.upload');
static const backupPeriodDelete = Permission._(r'backup.delete');
static const duplicatePeriodRead = Permission._(r'duplicate.read'); static const duplicatePeriodRead = Permission._(r'duplicate.read');
static const duplicatePeriodDelete = Permission._(r'duplicate.delete'); static const duplicatePeriodDelete = Permission._(r'duplicate.delete');
static const facePeriodCreate = Permission._(r'face.create'); static const facePeriodCreate = Permission._(r'face.create');
@@ -206,6 +210,10 @@ class Permission {
authPeriodChangePassword, authPeriodChangePassword,
authDevicePeriodDelete, authDevicePeriodDelete,
archivePeriodRead, archivePeriodRead,
backupPeriodList,
backupPeriodDownload,
backupPeriodUpload,
backupPeriodDelete,
duplicatePeriodRead, duplicatePeriodRead,
duplicatePeriodDelete, duplicatePeriodDelete,
facePeriodCreate, facePeriodCreate,
@@ -389,6 +397,10 @@ class PermissionTypeTransformer {
case r'auth.changePassword': return Permission.authPeriodChangePassword; case r'auth.changePassword': return Permission.authPeriodChangePassword;
case r'authDevice.delete': return Permission.authDevicePeriodDelete; case r'authDevice.delete': return Permission.authDevicePeriodDelete;
case r'archive.read': return Permission.archivePeriodRead; case r'archive.read': return Permission.archivePeriodRead;
case r'backup.list': return Permission.backupPeriodList;
case r'backup.download': return Permission.backupPeriodDownload;
case r'backup.upload': return Permission.backupPeriodUpload;
case r'backup.delete': return Permission.backupPeriodDelete;
case r'duplicate.read': return Permission.duplicatePeriodRead; case r'duplicate.read': return Permission.duplicatePeriodRead;
case r'duplicate.delete': return Permission.duplicatePeriodDelete; case r'duplicate.delete': return Permission.duplicatePeriodDelete;
case r'face.create': return Permission.facePeriodCreate; case r'face.create': return Permission.facePeriodCreate;

View File

@@ -372,7 +372,7 @@
"x-immich-state": "Alpha" "x-immich-state": "Alpha"
} }
}, },
"/admin/maintenance/backups/list": { "/admin/maintenance/backups": {
"get": { "get": {
"description": "Get the list of the successful and failed backups", "description": "Get the list of the successful and failed backups",
"operationId": "listBackups", "operationId": "listBackups",
@@ -407,11 +407,11 @@
"x-immich-admin-only": true, "x-immich-admin-only": true,
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Added" "state": "Added"
}, },
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Alpha" "state": "Alpha"
} }
], ],
@@ -435,11 +435,11 @@
], ],
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Added" "state": "Added"
}, },
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Alpha" "state": "Alpha"
} }
], ],
@@ -448,7 +448,7 @@
}, },
"/admin/maintenance/backups/upload": { "/admin/maintenance/backups/upload": {
"post": { "post": {
"description": "Uploads a new asset to the server.", "description": "Uploads .sql/.sql.gz file to restore backup from",
"operationId": "uploadBackup", "operationId": "uploadBackup",
"parameters": [], "parameters": [],
"requestBody": { "requestBody": {
@@ -478,22 +478,22 @@
"api_key": [] "api_key": []
} }
], ],
"summary": "Upload asset", "summary": "Upload database backup",
"tags": [ "tags": [
"Maintenance (admin)" "Maintenance (admin)"
], ],
"x-immich-admin-only": true, "x-immich-admin-only": true,
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Added" "state": "Added"
}, },
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Alpha" "state": "Alpha"
} }
], ],
"x-immich-permission": "maintenance", "x-immich-permission": "backup.upload",
"x-immich-state": "Alpha" "x-immich-state": "Alpha"
} }
}, },
@@ -535,15 +535,15 @@
"x-immich-admin-only": true, "x-immich-admin-only": true,
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Added" "state": "Added"
}, },
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Alpha" "state": "Alpha"
} }
], ],
"x-immich-permission": "maintenance", "x-immich-permission": "backup.delete",
"x-immich-state": "Alpha" "x-immich-state": "Alpha"
}, },
"get": { "get": {
@@ -591,22 +591,22 @@
"x-immich-admin-only": true, "x-immich-admin-only": true,
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Added" "state": "Added"
}, },
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Alpha" "state": "Alpha"
} }
], ],
"x-immich-permission": "maintenance", "x-immich-permission": "backup.download",
"x-immich-state": "Alpha" "x-immich-state": "Alpha"
} }
}, },
"/admin/maintenance/integrity": { "/admin/maintenance/detect-install": {
"get": { "get": {
"description": "Collect integrity checks and other heuristics about local data.", "description": "Collect integrity checks and other heuristics about local data.",
"operationId": "integrityCheck", "operationId": "detectPriorInstall",
"parameters": [], "parameters": [],
"responses": { "responses": {
"200": { "200": {
@@ -620,17 +620,17 @@
"description": "" "description": ""
} }
}, },
"summary": "Get integrity and heuristics", "summary": "Detect existing install",
"tags": [ "tags": [
"Maintenance (admin)" "Maintenance (admin)"
], ],
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Added" "state": "Added"
}, },
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Alpha" "state": "Alpha"
} }
], ],
@@ -684,7 +684,7 @@
"/admin/maintenance/status": { "/admin/maintenance/status": {
"get": { "get": {
"description": "Fetch information about the currently running maintenance action.", "description": "Fetch information about the currently running maintenance action.",
"operationId": "maintenanceStatus", "operationId": "getMaintenanceStatus",
"parameters": [], "parameters": [],
"responses": { "responses": {
"200": { "200": {
@@ -704,11 +704,11 @@
], ],
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Added" "state": "Added"
}, },
{ {
"version": "v9.9.9", "version": "v2.4.0",
"state": "Alpha" "state": "Alpha"
} }
], ],
@@ -18250,6 +18250,10 @@
"auth.changePassword", "auth.changePassword",
"authDevice.delete", "authDevice.delete",
"archive.read", "archive.read",
"backup.list",
"backup.download",
"backup.upload",
"backup.delete",
"duplicate.read", "duplicate.read",
"duplicate.delete", "duplicate.delete",
"face.create", "face.create",

View File

@@ -1891,7 +1891,7 @@ export function listBackups(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: MaintenanceListBackupsResponseDto; data: MaintenanceListBackupsResponseDto;
}>("/admin/maintenance/backups/list", { }>("/admin/maintenance/backups", {
...opts ...opts
})); }));
} }
@@ -1905,7 +1905,7 @@ export function startRestoreFlow(opts?: Oazapfts.RequestOpts) {
})); }));
} }
/** /**
* Upload asset * Upload database backup
*/ */
export function uploadBackup({ maintenanceUploadBackupDto }: { export function uploadBackup({ maintenanceUploadBackupDto }: {
maintenanceUploadBackupDto: MaintenanceUploadBackupDto; maintenanceUploadBackupDto: MaintenanceUploadBackupDto;
@@ -1941,13 +1941,13 @@ export function downloadBackup({ filename }: {
})); }));
} }
/** /**
* Get integrity and heuristics * Detect existing install
*/ */
export function integrityCheck(opts?: Oazapfts.RequestOpts) { export function detectPriorInstall(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: MaintenanceIntegrityResponseDto; data: MaintenanceIntegrityResponseDto;
}>("/admin/maintenance/integrity", { }>("/admin/maintenance/detect-install", {
...opts ...opts
})); }));
} }
@@ -1969,7 +1969,7 @@ export function maintenanceLogin({ maintenanceLoginDto }: {
/** /**
* Get maintenance mode status * Get maintenance mode status
*/ */
export function maintenanceStatus(opts?: Oazapfts.RequestOpts) { export function getMaintenanceStatus(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: MaintenanceStatusResponseDto; data: MaintenanceStatusResponseDto;
@@ -5343,6 +5343,10 @@ export enum Permission {
AuthChangePassword = "auth.changePassword", AuthChangePassword = "auth.changePassword",
AuthDeviceDelete = "authDevice.delete", AuthDeviceDelete = "authDevice.delete",
ArchiveRead = "archive.read", ArchiveRead = "archive.read",
BackupList = "backup.list",
BackupDownload = "backup.download",
BackupUpload = "backup.upload",
BackupDelete = "backup.delete",
DuplicateRead = "duplicate.read", DuplicateRead = "duplicate.read",
DuplicateDelete = "duplicate.delete", DuplicateDelete = "duplicate.delete",
FaceCreate = "face.create", FaceCreate = "face.create",

View File

@@ -4,6 +4,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Next,
Param, Param,
Post, Post,
Res, Res,
@@ -12,7 +13,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators'; import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
@@ -26,9 +27,10 @@ import {
} from 'src/dtos/maintenance.dto'; } from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum'; import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard'; import { Auth, Authenticated, FileResponse, 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 { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service'; import { MaintenanceService } from 'src/services/maintenance.service';
import { sendFile } from 'src/utils/file';
import { respondWithCookie } from 'src/utils/response'; import { respondWithCookie } from 'src/utils/response';
import { FilenameParamDto } from 'src/validation'; import { FilenameParamDto } from 'src/validation';
@@ -36,30 +38,30 @@ import { FilenameParamDto } from 'src/validation';
@Controller('admin/maintenance') @Controller('admin/maintenance')
export class MaintenanceController { export class MaintenanceController {
constructor( constructor(
private logger: LoggingRepository,
private service: MaintenanceService, private service: MaintenanceService,
private storageRepository: StorageRepository,
) {} ) {}
@Get('status') @Get('status')
@Endpoint({ @Endpoint({
summary: 'Get maintenance mode status', summary: 'Get maintenance mode status',
description: 'Fetch information about the currently running maintenance action.', description: 'Fetch information about the currently running maintenance action.',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
}) })
maintenanceStatus(): MaintenanceStatusResponseDto { getMaintenanceStatus(): MaintenanceStatusResponseDto {
return { return {
action: MaintenanceAction.End, action: MaintenanceAction.End,
}; };
} }
@Get('integrity') @Get('detect-install')
@Endpoint({ @Endpoint({
summary: 'Get integrity and heuristics', summary: 'Detect existing install',
description: 'Collect integrity checks and other heuristics about local data.', description: 'Collect integrity checks and other heuristics about local data.',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
}) })
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> { detectPriorInstall(): Promise<MaintenanceIntegrityResponseDto> {
return this.service.integrityCheck(); return this.service.detectPriorInstall();
} }
@Post('login') @Post('login')
@@ -94,11 +96,11 @@ export class MaintenanceController {
} }
} }
@Get('backups/list') @Get('backups')
@Endpoint({ @Endpoint({
summary: 'List backups', summary: 'List backups',
description: 'Get the list of the successful and failed backups', description: 'Get the list of the successful and failed backups',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
}) })
@Authenticated({ permission: Permission.Maintenance, admin: true }) @Authenticated({ permission: Permission.Maintenance, admin: true })
listBackups(): Promise<MaintenanceListBackupsResponseDto> { listBackups(): Promise<MaintenanceListBackupsResponseDto> {
@@ -110,21 +112,24 @@ export class MaintenanceController {
@Endpoint({ @Endpoint({
summary: 'Download backup', summary: 'Download backup',
description: 'Downloads the database backup file', description: 'Downloads the database backup file',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
}) })
@Authenticated({ permission: Permission.Maintenance, admin: true }) @Authenticated({ permission: Permission.BackupDownload, admin: true })
downloadBackup(@Param() { filename }: FilenameParamDto, @Res() res: Response) { async downloadBackup(
res.header('Content-Disposition', 'attachment'); @Param() { filename }: FilenameParamDto,
res.sendFile(this.service.getBackupPath(filename)); @Res() res: Response,
@Next() next: NextFunction,
): Promise<void> {
await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger);
} }
@Delete('backups/:filename') @Delete('backups/:filename')
@Endpoint({ @Endpoint({
summary: 'Delete backup', summary: 'Delete backup',
description: 'Delete a backup by its filename', description: 'Delete a backup by its filename',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
}) })
@Authenticated({ permission: Permission.Maintenance, admin: true }) @Authenticated({ permission: Permission.BackupDelete, admin: true })
async deleteBackup(@Param() { filename }: FilenameParamDto): Promise<void> { async deleteBackup(@Param() { filename }: FilenameParamDto): Promise<void> {
return this.service.deleteBackup(filename); return this.service.deleteBackup(filename);
} }
@@ -133,7 +138,7 @@ export class MaintenanceController {
@Endpoint({ @Endpoint({
summary: 'Start backup restore flow', summary: 'Start backup restore flow',
description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)', description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
}) })
async startRestoreFlow( async startRestoreFlow(
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
@@ -147,13 +152,13 @@ export class MaintenanceController {
} }
@Post('backups/upload') @Post('backups/upload')
@Authenticated({ permission: Permission.Maintenance, admin: true }) @Authenticated({ permission: Permission.BackupUpload, admin: true })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({ description: 'Backup Upload', type: MaintenanceUploadBackupDto }) @ApiBody({ description: 'Backup Upload', type: MaintenanceUploadBackupDto })
@Endpoint({ @Endpoint({
summary: 'Upload asset', summary: 'Upload database backup',
description: 'Uploads a new asset to the server.', description: 'Uploads .sql/.sql.gz file to restore backup from',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
}) })
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
uploadBackup( uploadBackup(

View File

@@ -127,6 +127,11 @@ export enum Permission {
ArchiveRead = 'archive.read', ArchiveRead = 'archive.read',
BackupList = 'backup.list',
BackupDownload = 'backup.download',
BackupUpload = 'backup.upload',
BackupDelete = 'backup.delete',
DuplicateRead = 'duplicate.read', DuplicateRead = 'duplicate.read',
DuplicateDelete = 'duplicate.delete', DuplicateDelete = 'duplicate.delete',

View File

@@ -37,8 +37,8 @@ export class MaintenanceWorkerController {
} }
@Get('admin/maintenance/integrity') @Get('admin/maintenance/integrity')
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> { detectPriorInstall(): Promise<MaintenanceIntegrityResponseDto> {
return this.service.integrityCheck(); return this.service.detectPriorInstall();
} }
@Post('admin/maintenance/login') @Post('admin/maintenance/login')

View File

@@ -177,7 +177,7 @@ describe(MaintenanceWorkerService.name, () => {
mocks.storage.readFile.mockResolvedValue(undefined as never); mocks.storage.readFile.mockResolvedValue(undefined as never);
mocks.storage.overwriteFile.mockRejectedValue(undefined as never); mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
await expect(sut.integrityCheck()).resolves.toMatchInlineSnapshot(` await expect(sut.detectPriorInstall()).resolves.toMatchInlineSnapshot(`
{ {
"storage": [ "storage": [
{ {

View File

@@ -29,7 +29,7 @@ import { type ServerService as _ServerService } from 'src/services/server.servic
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
import { deleteBackup, isValidBackupName, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups'; import { deleteBackup, isValidBackupName, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups';
import { getConfig } from 'src/utils/config'; import { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl, integrityCheck } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
/** /**
@@ -198,8 +198,8 @@ export class MaintenanceWorkerService {
} }
} }
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> { detectPriorInstall(): Promise<MaintenanceIntegrityResponseDto> {
return integrityCheck(this.storageRepository); return detectPriorInstall(this.storageRepository);
} }
async login(jwt?: string): Promise<MaintenanceAuthDto> { async login(jwt?: string): Promise<MaintenanceAuthDto> {

View File

@@ -63,7 +63,7 @@ describe(MaintenanceService.name, () => {
mocks.storage.readFile.mockResolvedValue(undefined as never); mocks.storage.readFile.mockResolvedValue(undefined as never);
mocks.storage.overwriteFile.mockRejectedValue(undefined as never); mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
await expect(sut.integrityCheck()).resolves.toMatchInlineSnapshot(` await expect(sut.detectPriorInstall()).resolves.toMatchInlineSnapshot(`
{ {
"storage": [ "storage": [
{ {

View File

@@ -3,14 +3,15 @@ import { basename, join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto, MaintenanceIntegrityResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto, MaintenanceIntegrityResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; import { CacheControl, MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
import { deleteBackup, isValidBackupName, listBackups, uploadBackup } from 'src/utils/backups'; import { deleteBackup, isValidBackupName, listBackups, uploadBackup } from 'src/utils/backups';
import { ImmichFileResponse } from 'src/utils/file';
import { import {
createMaintenanceLoginUrl, createMaintenanceLoginUrl,
detectPriorInstall,
generateMaintenanceSecret, generateMaintenanceSecret,
integrityCheck,
signMaintenanceJwt, signMaintenanceJwt,
} from 'src/utils/maintenance'; } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
@@ -26,8 +27,8 @@ export class MaintenanceService extends BaseService {
.then((state) => state ?? { isMaintenanceMode: false }); .then((state) => state ?? { isMaintenanceMode: false });
} }
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> { detectPriorInstall(): Promise<MaintenanceIntegrityResponseDto> {
return integrityCheck(this.storageRepository); return detectPriorInstall(this.storageRepository);
} }
async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> { async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> {
@@ -98,6 +99,15 @@ export class MaintenanceService extends BaseService {
return uploadBackup(this.backupRepos, file); 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 { getBackupPath(filename: string): string {
if (!isValidBackupName(filename)) { if (!isValidBackupName(filename)) {
throw new BadRequestException('Invalid backup name!'); throw new BadRequestException('Invalid backup name!');

View File

@@ -42,7 +42,7 @@ const cacheControlHeaders: Record<CacheControl, string | null> = {
export const sendFile = async ( export const sendFile = async (
res: Response, res: Response,
next: NextFunction, next: NextFunction,
handler: () => Promise<ImmichFileResponse>, handler: () => Promise<ImmichFileResponse> | ImmichFileResponse,
logger: LoggingRepository, logger: LoggingRepository,
): Promise<void> => { ): Promise<void> => {
// promisified version of 'res.sendFile' for cleaner async handling // promisified version of 'res.sendFile' for cleaner async handling

View File

@@ -77,7 +77,9 @@ export function generateMaintenanceSecret(): string {
return randomBytes(64).toString('hex'); return randomBytes(64).toString('hex');
} }
export async function integrityCheck(storageRepository: StorageRepository): Promise<MaintenanceIntegrityResponseDto> { export async function detectPriorInstall(
storageRepository: StorageRepository,
): Promise<MaintenanceIntegrityResponseDto> {
return { return {
storage: await Promise.all( storage: await Promise.all(
Object.values(StorageFolder).map(async (folder) => { Object.values(StorageFolder).map(async (folder) => {

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import MaintenanceBackupsList from '$lib/components/maintenance/MaintenanceBackupsList.svelte'; import MaintenanceBackupsList from '$lib/components/maintenance/MaintenanceBackupsList.svelte';
import { integrityCheck, type MaintenanceIntegrityResponseDto } from '@immich/sdk'; import { detectPriorInstall, type MaintenanceIntegrityResponseDto } from '@immich/sdk';
import { Button, Card, CardBody, Heading, HStack, Icon, Scrollable, Stack, Text } from '@immich/ui'; import { Button, Card, CardBody, Heading, HStack, Icon, Scrollable, Stack, Text } from '@immich/ui';
import { mdiAlert, mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose, mdiRefresh } from '@mdi/js'; import { mdiAlert, mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose, mdiRefresh } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -16,7 +16,7 @@
let integrity: MaintenanceIntegrityResponseDto | undefined = $state(); let integrity: MaintenanceIntegrityResponseDto | undefined = $state();
async function reload() { async function reload() {
integrity = await integrityCheck(); integrity = await detectPriorInstall();
} }
onMount(reload); onMount(reload);

View File

@@ -1,7 +1,7 @@
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { maintenanceStore } from '$lib/stores/maintenance.store'; import { maintenanceStore } from '$lib/stores/maintenance.store';
import { websocketStore } from '$lib/stores/websocket'; import { websocketStore } from '$lib/stores/websocket';
import { MaintenanceAction, maintenanceLogin, maintenanceStatus } from '@immich/sdk'; import { MaintenanceAction, maintenanceLogin } from '@immich/sdk';
export function maintenanceCreateUrl(url: URL) { export function maintenanceCreateUrl(url: URL) {
const target = new URL(AppRoute.MAINTENANCE, url.origin); const target = new URL(AppRoute.MAINTENANCE, url.origin);
@@ -36,7 +36,7 @@ export const loadMaintenanceAuth = async () => {
export const loadMaintenanceStatus = async () => { export const loadMaintenanceStatus = async () => {
while (true) { while (true) {
try { try {
const status = await maintenanceStatus(); const status = await getMaintenanceStatus();
maintenanceStore.status.set(status); maintenanceStore.status.set(status);
if (status.action === MaintenanceAction.End) { if (status.action === MaintenanceAction.End) {