mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 17:23:21 +03:00
feat: download backups from list
This commit is contained in:
@@ -545,6 +545,62 @@
|
|||||||
],
|
],
|
||||||
"x-immich-permission": "maintenance",
|
"x-immich-permission": "maintenance",
|
||||||
"x-immich-state": "Alpha"
|
"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": {
|
"/admin/maintenance/login": {
|
||||||
@@ -16758,17 +16814,10 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
|
||||||
"failedBackups": {
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"backups",
|
"backups"
|
||||||
"failedBackups"
|
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
SetMaintenanceModeDto,
|
SetMaintenanceModeDto,
|
||||||
} from 'src/dtos/maintenance.dto';
|
} from 'src/dtos/maintenance.dto';
|
||||||
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
|
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 { LoginDetails } from 'src/services/auth.service';
|
||||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||||
import { respondWithCookie } from 'src/utils/response';
|
import { respondWithCookie } from 'src/utils/response';
|
||||||
@@ -92,6 +92,19 @@ export class MaintenanceController {
|
|||||||
return this.service.listBackups();
|
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')
|
@Delete('backups/:filename')
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Delete backup',
|
summary: 'Delete backup',
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export class MaintenanceWorkerController {
|
|||||||
return this.service.listBackups();
|
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')
|
@Delete('admin/maintenance/backups/:filename')
|
||||||
@MaintenanceRoute()
|
@MaintenanceRoute()
|
||||||
async deleteBackup(@Param() { filename }: FilenameParamDto): Promise<void> {
|
async deleteBackup(@Param() { filename }: FilenameParamDto): Promise<void> {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { parse } from 'cookie';
|
import { parse } from 'cookie';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import { jwtVerify } from 'jose';
|
import { jwtVerify } from 'jose';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { IncomingHttpHeaders } from 'node:http';
|
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 { MaintenanceAuthDto, MaintenanceStatusResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||||
import { ServerConfigDto } from 'src/dtos/server.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 { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository';
|
||||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||||
import { AppRepository } from 'src/repositories/app.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 BaseService as _BaseService } from 'src/services/base.service';
|
||||||
import { type ServerService as _ServerService } from 'src/services/server.service';
|
import { type ServerService as _ServerService } from 'src/services/server.service';
|
||||||
import { MaintenanceModeState } from 'src/types';
|
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 { getConfig } from 'src/utils/config';
|
||||||
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
|
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
@@ -287,6 +289,14 @@ export class MaintenanceWorkerService {
|
|||||||
return uploadBackup(file);
|
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() {
|
private get backupRepos() {
|
||||||
return {
|
return {
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { MaintenanceAuthDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
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 { BaseService } from 'src/services/base.service';
|
||||||
import { MaintenanceModeState } from 'src/types';
|
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 { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
|
|
||||||
@@ -87,6 +89,14 @@ export class MaintenanceService extends BaseService {
|
|||||||
return uploadBackup(file);
|
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() {
|
private get backupRepos() {
|
||||||
return {
|
return {
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
|
|||||||
@@ -10,14 +10,25 @@
|
|||||||
setMaintenanceMode,
|
setMaintenanceMode,
|
||||||
type MaintenanceUploadBackupDto,
|
type MaintenanceUploadBackupDto,
|
||||||
} from '@immich/sdk';
|
} 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 { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
backups?: string[];
|
backups?: string[];
|
||||||
showDelete?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let props: Props = $props();
|
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);
|
let uploadProgress = $state(-1);
|
||||||
|
|
||||||
async function upload() {
|
async function upload() {
|
||||||
@@ -160,14 +199,16 @@
|
|||||||
<Button size="small" disabled={deleting.has(backup.filename)} onclick={() => restore(backup.filename)}
|
<Button size="small" disabled={deleting.has(backup.filename)} onclick={() => restore(backup.filename)}
|
||||||
>Restore</Button
|
>Restore</Button
|
||||||
>
|
>
|
||||||
{#if props.showDelete}
|
<IconButton
|
||||||
<Button
|
shape="round"
|
||||||
size="small"
|
variant="ghost"
|
||||||
color="danger"
|
color="secondary"
|
||||||
disabled={deleting.has(backup.filename)}
|
icon={mdiDotsVertical}
|
||||||
onclick={() => remove(backup.filename)}>Delete</Button
|
class="flex-shrink-0"
|
||||||
>
|
disabled={deleting.has(backup.filename)}
|
||||||
{/if}
|
onclick={(event: Event) => handleOpen(event, { position: 'top-right' }, backup.filename)}
|
||||||
|
aria-label="Open menu"
|
||||||
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user