mirror of
https://github.com/immich-app/immich.git
synced 2025-12-17 01:11:13 +03:00
feat: upload backups
This commit is contained in:
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@@ -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
|
||||
|
||||
58
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
58
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`\.`);
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
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<FileUploaderParams, 'deviceAssetId' | 'assetFile'> & {
|
||||
files: File[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user