feat: download backups from list

This commit is contained in:
izzy
2025-11-21 14:47:11 +00:00
parent a3c6d71a58
commit 174670a1b7
6 changed files with 154 additions and 24 deletions

View File

@@ -545,6 +545,62 @@
],
"x-immich-permission": "maintenance",
"x-immich-state": "Alpha"
},
"get": {
"description": "Downloads the database backup file",
"operationId": "downloadBackup",
"parameters": [
{
"name": "filename",
"required": true,
"in": "path",
"schema": {
"format": "string",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Download backup",
"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/login": {
@@ -16758,17 +16814,10 @@
"type": "string"
},
"type": "array"
},
"failedBackups": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"backups",
"failedBackups"
"backups"
],
"type": "object"
},

View File

@@ -26,7 +26,7 @@ import {
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { respondWithCookie } from 'src/utils/response';
@@ -92,6 +92,19 @@ export class MaintenanceController {
return this.service.listBackups();
}
@Get('backups/:filename')
@FileResponse()
@Endpoint({
summary: 'Download backup',
description: 'Downloads the database backup file',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async downloadBackup(@Param() { filename }: FilenameParamDto, @Res() res: Response) {
res.header('Content-Disposition', 'attachment');
res.sendFile(this.service.getBackupPath(filename));
}
@Delete('backups/:filename')
@Endpoint({
summary: 'Delete backup',

View File

@@ -71,6 +71,13 @@ export class MaintenanceWorkerController {
return this.service.listBackups();
}
@Get('admin/maintenance/backups/:filename')
@MaintenanceRoute()
async downloadBackup(@Param() { filename }: FilenameParamDto, @Res() res: Response) {
res.header('Content-Disposition', 'attachment');
res.sendFile(this.service.getBackupPath(filename));
}
@Delete('admin/maintenance/backups/:filename')
@MaintenanceRoute()
async deleteBackup(@Param() { filename }: FilenameParamDto): Promise<void> {

View File

@@ -1,12 +1,14 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { parse } from 'cookie';
import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http';
import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto, MaintenanceStatusResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { 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';
@@ -20,7 +22,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, uploadBackup } from 'src/utils/backups';
import { deleteBackup, isValidBackupName, 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';
@@ -287,6 +289,14 @@ export class MaintenanceWorkerService {
return uploadBackup(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,

View File

@@ -1,10 +1,12 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types';
import { deleteBackup, listBackups, uploadBackup } from 'src/utils/backups';
import { deleteBackup, isValidBackupName, listBackups, uploadBackup } from 'src/utils/backups';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@@ -87,6 +89,14 @@ export class MaintenanceService extends BaseService {
return uploadBackup(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,

View File

@@ -10,14 +10,25 @@
setMaintenanceMode,
type MaintenanceUploadBackupDto,
} from '@immich/sdk';
import { Button, Card, CardBody, HStack, modalManager, Stack, Text } from '@immich/ui';
import {
Button,
Card,
CardBody,
HStack,
IconButton,
menuManager,
modalManager,
Stack,
Text,
type ContextMenuBaseProps,
} from '@immich/ui';
import { mdiDotsVertical, mdiDownload, mdiTrashCanOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
backups?: string[];
showDelete?: boolean;
}
let props: Props = $props();
@@ -93,6 +104,34 @@
}
}
async function download(filename: string) {
location.href = getBaseUrl() + '/admin/maintenance/backups/' + filename;
}
const handleOpen = async (event: Event, props: Partial<ContextMenuBaseProps>, filename: string) => {
await menuManager.show({
...props,
target: event.currentTarget as HTMLElement,
items: [
{
title: 'Download',
icon: mdiDownload,
onSelect() {
download(filename);
},
},
{
title: 'Delete',
icon: mdiTrashCanOutline,
color: 'danger',
onSelect() {
remove(filename);
},
},
],
});
};
let uploadProgress = $state(-1);
async function upload() {
@@ -160,14 +199,16 @@
<Button size="small" disabled={deleting.has(backup.filename)} onclick={() => restore(backup.filename)}
>Restore</Button
>
{#if props.showDelete}
<Button
size="small"
color="danger"
disabled={deleting.has(backup.filename)}
onclick={() => remove(backup.filename)}>Delete</Button
>
{/if}
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiDotsVertical}
class="flex-shrink-0"
disabled={deleting.has(backup.filename)}
onclick={(event: Event) => handleOpen(event, { position: 'top-right' }, backup.filename)}
aria-label="Open menu"
/>
</HStack>
</CardBody>
</Card>