From 19ba23056c7b3f6c0c210d043880ae91dc86b17b Mon Sep 17 00:00:00 2001 From: izzy Date: Fri, 21 Nov 2025 12:52:27 +0000 Subject: [PATCH] feat: upload backups --- mobile/openapi/README.md | 1 + .../lib/api/maintenance_admin_api.dart | 58 ++++++++++++++++++ open-api/immich-openapi-specs.json | 60 +++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 15 +++++ .../src/controllers/maintenance.controller.ts | 36 ++++++++++- server/src/dtos/maintenance.dto.ts | 6 ++ .../maintenance-worker.controller.ts | 26 +++++++- .../maintenance/maintenance-worker.service.ts | 6 +- server/src/services/maintenance.service.ts | 6 +- server/src/utils/backups.ts | 14 +++++ .../maintenance/MaintenanceBackupsList.svelte | 53 +++++++++++++++- web/src/lib/utils/file-uploader.ts | 29 ++++++--- 12 files changed, 297 insertions(+), 13 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index a2704ab1bf..3862be3cf3 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -165,6 +165,7 @@ Class | Method | HTTP request | Description *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* | [**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 *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/maintenance_admin_api.dart b/mobile/openapi/lib/api/maintenance_admin_api.dart index 1d6e3455b6..ecef84b691 100644 --- a/mobile/openapi/lib/api/maintenance_admin_api.dart +++ b/mobile/openapi/lib/api/maintenance_admin_api.dart @@ -304,4 +304,62 @@ class MaintenanceAdminApi { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } } + + /// Upload asset + /// + /// Uploads a new asset to the server. + /// + /// 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 asset + /// + /// Uploads a new asset to the server. + /// + /// 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/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 61949c42ea..81edd09a2a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -446,6 +446,57 @@ "x-immich-state": "Alpha" } }, + "/admin/maintenance/backups/upload": { + "post": { + "description": "Uploads a new asset to the server.", + "operationId": "uploadBackup", + "parameters": [], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MaintenanceUploadBackupDto" + } + } + }, + "description": "Backup Upload", + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Upload asset", + "tags": [ + "Maintenance (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v9.9.9", + "state": "Added" + }, + { + "version": "v9.9.9", + "state": "Alpha" + } + ], + "x-immich-permission": "maintenance", + "x-immich-state": "Alpha" + } + }, "/admin/maintenance/backups/{filename}": { "delete": { "description": "Delete a backup by its filename", @@ -16753,6 +16804,15 @@ ], "type": "object" }, + "MaintenanceUploadBackupDto": { + "properties": { + "file": { + "format": "binary", + "type": "string" + } + }, + "type": "object" + }, "ManualJobName": { "enum": [ "person-cleanup", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index a9cd16b0c9..022d7ac5f8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -48,6 +48,9 @@ export type MaintenanceListBackupsResponseDto = { backups: string[]; failedBackups: string[]; }; +export type MaintenanceUploadBackupDto = { + file?: Blob; +}; export type MaintenanceLoginDto = { token?: string; }; @@ -1875,6 +1878,18 @@ export function startRestoreFlow(opts?: Oazapfts.RequestOpts) { method: "POST" })); } +/** + * Upload asset + */ +export function uploadBackup({ maintenanceUploadBackupDto }: { + maintenanceUploadBackupDto: MaintenanceUploadBackupDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/admin/maintenance/backups/upload", oazapfts.multipart({ + ...opts, + method: "POST", + body: maintenanceUploadBackupDto + }))); +} /** * Delete backup */ diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index 1705169208..2a92d04b5a 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -1,5 +1,19 @@ -import { BadRequestException, Body, Controller, Delete, Get, Param, Post, Res } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { + BadRequestException, + Body, + Controller, + Delete, + FileTypeValidator, + Get, + Param, + ParseFilePipe, + Post, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -8,6 +22,7 @@ import { MaintenanceListBackupsResponseDto, MaintenanceLoginDto, MaintenanceStatusResponseDto, + MaintenanceUploadBackupDto, SetMaintenanceModeDto, } from 'src/dtos/maintenance.dto'; import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum'; @@ -104,4 +119,21 @@ export class MaintenanceController { values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }], }); } + + @Post('backups/upload') + @Authenticated({ permission: Permission.Maintenance, admin: true }) + @ApiConsumes('multipart/form-data') + @ApiBody({ description: 'Backup Upload', type: MaintenanceUploadBackupDto }) + @Endpoint({ + summary: 'Upload asset', + description: 'Uploads a new asset to the server.', + history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), + }) + @UseInterceptors(FileInterceptor('file')) + uploadBackup( + @UploadedFile(new ParseFilePipe({ validators: [new FileTypeValidator({ fileType: 'application/gzip' })] })) + file: Express.Multer.File, + ): Promise { + return this.service.uploadBackup(file); + } } diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts index 53f4520ca9..e419f32c3d 100644 --- a/server/src/dtos/maintenance.dto.ts +++ b/server/src/dtos/maintenance.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from '@nestjs/swagger'; import { MaintenanceAction } from 'src/enum'; import { ValidateEnum, ValidateString } from 'src/validation'; @@ -31,3 +32,8 @@ export class MaintenanceListBackupsResponseDto { backups!: string[]; failedBackups!: string[]; } + +export class MaintenanceUploadBackupDto { + @ApiProperty({ type: 'string', format: 'binary', required: false }) + file?: any; +} diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts index 40f30eab40..e85069d82d 100644 --- a/server/src/maintenance/maintenance-worker.controller.ts +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -1,4 +1,18 @@ -import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + FileTypeValidator, + Get, + Param, + ParseFilePipe, + Post, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { Request, Response } from 'express'; import { MaintenanceAuthDto, @@ -62,4 +76,14 @@ export class MaintenanceWorkerController { async deleteBackup(@Param() { filename }: FilenameParamDto): Promise { return this.service.deleteBackup(filename); } + + @Post('admin/maintenance/backups/upload') + @MaintenanceRoute() + @UseInterceptors(FileInterceptor('file')) + uploadBackup( + @UploadedFile(new ParseFilePipe({ validators: [new FileTypeValidator({ fileType: 'application/gzip' })] })) + file: Express.Multer.File, + ): Promise { + return this.service.uploadBackup(file); + } } diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index 6bc74ebae9..559c0b106e 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -20,7 +20,7 @@ import { type ApiService as _ApiService } from 'src/services/api.service'; import { type BaseService as _BaseService } from 'src/services/base.service'; import { type ServerService as _ServerService } from 'src/services/server.service'; import { MaintenanceModeState } from 'src/types'; -import { deleteBackup, listBackups, restoreBackup } from 'src/utils/backups'; +import { deleteBackup, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups'; import { getConfig } from 'src/utils/config'; import { createMaintenanceLoginUrl } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; @@ -283,6 +283,10 @@ export class MaintenanceWorkerService { return deleteBackup(this.backupRepos, filename); } + async uploadBackup(file: Express.Multer.File): Promise { + return uploadBackup(file); + } + private get backupRepos() { return { logger: this.logger, diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index 8764bb70d6..19c6858e44 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -4,7 +4,7 @@ import { MaintenanceAuthDto, SetMaintenanceModeDto } from 'src/dtos/maintenance. import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { MaintenanceModeState } from 'src/types'; -import { deleteBackup, listBackups } from 'src/utils/backups'; +import { deleteBackup, listBackups, uploadBackup } from 'src/utils/backups'; import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; @@ -83,6 +83,10 @@ export class MaintenanceService extends BaseService { return deleteBackup(this.backupRepos, filename); } + async uploadBackup(file: Express.Multer.File): Promise { + return uploadBackup(file); + } + private get backupRepos() { return { logger: this.logger, diff --git a/server/src/utils/backups.ts b/server/src/utils/backups.ts index 5774c6636d..eef799bdf6 100644 --- a/server/src/utils/backups.ts +++ b/server/src/utils/backups.ts @@ -1,5 +1,7 @@ +import { BadRequestException } from '@nestjs/common'; import { debounce } from 'lodash'; import { DateTime } from 'luxon'; +import { stat, writeFile } from 'node:fs/promises'; import path, { join } from 'node:path'; import { PassThrough, Readable, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; @@ -269,6 +271,18 @@ export async function listBackups({ }; } +export async function uploadBackup(file: Express.Multer.File): Promise { + const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); + const path = join(backupsFolder, file.originalname); + + try { + await stat(path); + throw new BadRequestException('File already exists!'); + } catch { + await writeFile(path, file.buffer); + } +} + function createSqlProgressStreams(cb: (progress: number) => void) { const STDIN_START_MARKER = new TextEncoder().encode('FROM stdin'); const STDIN_END_MARKER = new TextEncoder().encode(String.raw`\.`); diff --git a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte index 2e553f0c28..2d9c7ebd97 100644 --- a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte +++ b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte @@ -1,6 +1,15 @@ + + + {#if uploadProgress === -1} + + Upload database backup file + + + {:else} + + Uploading... +
+
+
+
+ {/if} +
+
+ {#each backups as backup (backup.filename)} @@ -102,6 +152,7 @@ {/if} {/if}
+ diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 516d682625..86ce45964e 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -43,19 +43,23 @@ export const addDummyItems = () => { export const uploadExecutionQueue = new ExecutorQueue({ concurrency: 2 }); +type FilePickerParam = { multiple?: boolean; extensions?: string[] }; type FileUploadParam = { multiple?: boolean; albumId?: string }; -export const openFileUploadDialog = async (options: FileUploadParam = {}) => { - const { albumId, multiple = true } = options; - const extensions = uploadManager.getExtensions(); +export const openFilePicker = async (options: FilePickerParam = {}) => { + const { multiple = true, extensions } = options; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { try { const fileSelector = document.createElement('input'); fileSelector.type = 'file'; fileSelector.multiple = multiple; - fileSelector.accept = extensions.join(','); + + if (extensions) { + fileSelector.accept = extensions.join(','); + } + fileSelector.addEventListener( 'change', (e: Event) => { @@ -63,9 +67,9 @@ export const openFileUploadDialog = async (options: FileUploadParam = {}) => { if (!target.files) { return; } - const files = Array.from(target.files); - resolve(fileUploadHandler({ files, albumId })); + const files = Array.from(target.files); + resolve(files); }, { passive: true }, ); @@ -78,6 +82,17 @@ export const openFileUploadDialog = async (options: FileUploadParam = {}) => { }); }; +export const openFileUploadDialog = async (options: FileUploadParam = {}) => { + const { albumId, multiple = true } = options; + const extensions = uploadManager.getExtensions(); + const files = await openFilePicker({ + multiple, + extensions, + }); + + return fileUploadHandler({ files, albumId }); +}; + type FileUploadHandlerParams = Omit & { files: File[]; };