feat: upload backups

This commit is contained in:
izzy
2025-11-21 12:52:27 +00:00
parent 3d2d7fa64c
commit 19ba23056c
12 changed files with 297 additions and 13 deletions

View File

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

View File

@@ -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<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 asset
///
/// Uploads a new asset to the server.
///
/// 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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<void> {
return uploadBackup(file);
}
private get backupRepos() {
return {
logger: this.logger,

View File

@@ -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<void> {
return uploadBackup(file);
}
private get backupRepos() {
return {
logger: this.logger,

View File

@@ -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<void> {
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`\.`);

View File

@@ -1,6 +1,15 @@
<script lang="ts">
import { uploadRequest } from '$lib/utils';
import { openFilePicker } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { deleteBackup, listBackups, MaintenanceAction, setMaintenanceMode } from '@immich/sdk';
import {
deleteBackup,
getBaseUrl,
listBackups,
MaintenanceAction,
setMaintenanceMode,
type MaintenanceUploadBackupDto,
} from '@immich/sdk';
import { Button, Card, CardBody, HStack, modalManager, Stack, Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -83,9 +92,50 @@
}
}
}
let uploadProgress = $state(-1);
async function upload() {
const [file] = await openFilePicker({ multiple: false });
const formData = new FormData();
formData.append('file', file);
await uploadRequest<MaintenanceUploadBackupDto>({
url: getBaseUrl() + '/admin/maintenance/backups/upload',
data: formData,
onUploadProgress(event) {
uploadProgress = event.loaded / event.total;
},
});
uploadProgress = 1;
const { backups: newList } = await listBackups();
backups = mapBackups(newList);
uploadProgress = -1;
}
</script>
<Stack gap={2} class="mt-4 text-left">
<Card>
<CardBody>
{#if uploadProgress === -1}
<HStack>
<Text class="flex-grow">Upload database backup file</Text>
<Button size="small" onclick={upload}>Select file</Button>
</HStack>
{:else}
<HStack>
<Text class="flex-grow">Uploading...</Text>
<div class="flex-grow h-[10px] bg-gray-300 rounded-full overflow-hidden">
<div class="h-full bg-blue-600 transition-all duration-700" style="width: {uploadProgress * 100}%"></div>
</div>
</HStack>
{/if}
</CardBody>
</Card>
{#each backups as backup (backup.filename)}
<Card>
<CardBody>
@@ -102,6 +152,7 @@
{/if}
{/if}
</Stack>
<Button size="small" disabled={deleting.has(backup.filename)} onclick={() => restore(backup.filename)}
>Restore</Button
>

View File

@@ -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<string[]>((resolve, reject) => {
return new Promise<File[]>((resolve, reject) => {
try {
const fileSelector = document.createElement('input');
fileSelector.type = 'file';
fileSelector.multiple = multiple;
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<FileUploaderParams, 'deviceAssetId' | 'assetFile'> & {
files: File[];
};