From 174670a1b7c4f9f99ebc25af6d2dd84f4b60a34a Mon Sep 17 00:00:00 2001 From: izzy Date: Fri, 21 Nov 2025 14:47:11 +0000 Subject: [PATCH] feat: download backups from list --- open-api/immich-openapi-specs.json | 65 ++++++++++++++++--- .../src/controllers/maintenance.controller.ts | 15 ++++- .../maintenance-worker.controller.ts | 7 ++ .../maintenance/maintenance-worker.service.ts | 16 ++++- server/src/services/maintenance.service.ts | 14 +++- .../maintenance/MaintenanceBackupsList.svelte | 61 ++++++++++++++--- 6 files changed, 154 insertions(+), 24 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 81edd09a2a..9fead8d516 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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" }, diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index 2a92d04b5a..b57ac7beb9 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -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', diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts index e85069d82d..8a6215893f 100644 --- a/server/src/maintenance/maintenance-worker.controller.ts +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -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 { diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index 620a24b41e..5eec6adf10 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -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, diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index 69551b308a..56da5ff4a2 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -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, diff --git a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte index 92f3d51651..495c198555 100644 --- a/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte +++ b/web/src/lib/components/maintenance/MaintenanceBackupsList.svelte @@ -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, 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 @@ - {#if props.showDelete} - - {/if} + handleOpen(event, { position: 'top-right' }, backup.filename)} + aria-label="Open menu" + />