From 79557cb971f5e70e7c5c917e7c758e9045c5b201 Mon Sep 17 00:00:00 2001 From: Narciso Date: Mon, 31 Aug 2026 14:37:24 -0400 Subject: [PATCH] feat: allow download backups --- .../database/backups/show-backup-files.tsx | 155 ++++++++++++++++++ .../database/backups/show-backups.tsx | 2 + apps/dokploy/pages/api/backups/download.ts | 93 +++++++++++ apps/dokploy/server/api/routers/backup.ts | 27 +-- apps/dokploy/server/api/utils/backup-files.ts | 104 ++++++++++++ packages/server/src/db/schema/audit-log.ts | 1 + packages/server/src/utils/backups/index.ts | 20 +-- packages/server/src/utils/backups/utils.ts | 14 ++ .../server/src/utils/process/execAsync.ts | 41 ++++- 9 files changed, 425 insertions(+), 32 deletions(-) create mode 100644 apps/dokploy/components/dashboard/database/backups/show-backup-files.tsx create mode 100644 apps/dokploy/pages/api/backups/download.ts create mode 100644 apps/dokploy/server/api/utils/backup-files.ts diff --git a/apps/dokploy/components/dashboard/database/backups/show-backup-files.tsx b/apps/dokploy/components/dashboard/database/backups/show-backup-files.tsx new file mode 100644 index 000000000..45033258c --- /dev/null +++ b/apps/dokploy/components/dashboard/database/backups/show-backup-files.tsx @@ -0,0 +1,155 @@ +import { DatabaseBackup, Download, FolderOpen, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; +import { formatBytes } from "./restore-backup"; + +interface Props { + backupId: string; +} + +export const ShowBackupFiles = ({ backupId }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [startingDownload, setStartingDownload] = useState(null); + + const { + data: files, + isLoading, + isError, + error, + } = api.backup.listBackupFilesByBackupId.useQuery( + { backupId }, + { enabled: isOpen }, + ); + + return ( + + + + + + + + + Backup Files + + + + + + Backup Files + + Files stored in the destination for this backup. + + + + {isError && {error.message}} + + {isLoading && ( +
+ +
+ )} + + {!isLoading && !isError && files?.length === 0 && ( +
+ + + No backup files found yet + +
+ )} + + {!isError && !!files?.length && ( + + + + + File + Date + Size + + + + + {files.map((file) => ( + + + {file.Name} + + + {file.ModTime + ? new Date(file.ModTime).toLocaleString() + : "—"} + + + {formatBytes(file.Size)} + + + + + + ))} + +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index ebffaccb3..4c78b0e96 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -36,6 +36,7 @@ import type { ServiceType } from "../../application/advanced/show-resources"; import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal"; import { HandleBackup } from "./handle-backup"; import { RestoreBackup } from "./restore-backup"; +import { ShowBackupFiles } from "./show-backup-files"; interface Props { id: string; @@ -334,6 +335,7 @@ export const ShowBackups = ({ + stream.destroy()); + + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Disposition", `attachment; filename="${file}"`); + res.setHeader("Content-Length", String(backupFile.Size)); + + stream.on("error", (error) => { + console.error(redactRcloneCredentials(String(error))); + res.destroy(); + }); + stream.pipe(res); + } catch (error) { + console.error(redactRcloneCredentials(String(error))); + if (res.headersSent) { + return res.destroy(); + } + const status = + error instanceof TRPCError ? getHTTPStatusCodeFromError(error) : 500; + return res + .status(status) + .json({ error: "Error downloading the backup file" }); + } +} diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index 9bc73ce77..1d5f56144 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -57,6 +57,11 @@ import { withPermission, } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import type { RcloneFile } from "@/server/api/utils/backup-files"; +import { + listRcloneFiles, + resolveBackupAccess, +} from "@/server/api/utils/backup-files"; import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits"; import { apiCreateBackup, @@ -67,18 +72,6 @@ import { } from "@/server/db/schema"; import { removeJob, schedule, updateJob } from "@/server/utils/backup"; -interface RcloneFile { - Path: string; - Name: string; - Size: number; - IsDir: boolean; - Tier?: string; - Hashes?: { - MD5?: string; - SHA1?: string; - }; -} - export const backupRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateBackup) @@ -563,6 +556,16 @@ export const backupRouter = createTRPCRouter({ } }), + listBackupFilesByBackupId: withPermission("backup", "read") + .input(z.object({ backupId: z.string() })) + .query(async ({ input, ctx }) => { + const { destination, folder } = await resolveBackupAccess( + ctx, + input.backupId, + ); + return await listRcloneFiles(destination, folder); + }), + restoreBackupWithLogs: protectedProcedure .meta({ openapi: { diff --git a/apps/dokploy/server/api/utils/backup-files.ts b/apps/dokploy/server/api/utils/backup-files.ts new file mode 100644 index 000000000..ad515a130 --- /dev/null +++ b/apps/dokploy/server/api/utils/backup-files.ts @@ -0,0 +1,104 @@ +import { ExecError, findBackupById } from "@dokploy/server"; +import type { Destination } from "@dokploy/server/services/destination"; +import { findDestinationById } from "@dokploy/server/services/destination"; +import type { PermissionCtx } from "@dokploy/server/services/permission"; +import { + checkServicePermissionAndAccess, + findMemberByUserId, +} from "@dokploy/server/services/permission"; +import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact"; +import { + getBackupFolder, + getS3Credentials, +} from "@dokploy/server/utils/backups/utils"; +import { execAsync } from "@dokploy/server/utils/process/execAsync"; +import { TRPCError } from "@trpc/server"; +import { quote } from "shell-quote"; + +export interface RcloneFile { + Path: string; + Name: string; + Size: number; + IsDir: boolean; + ModTime?: string; + Tier?: string; + Hashes?: { + MD5?: string; + SHA1?: string; + }; +} + +export const resolveBackupAccess = async ( + ctx: PermissionCtx, + backupId: string, +) => { + const backup = await findBackupById(backupId); + + const serviceId = + backup.postgresId || + backup.mysqlId || + backup.mariadbId || + backup.mongoId || + backup.libsqlId || + backup.composeId; + + if (serviceId) { + await checkServicePermissionAndAccess(ctx, serviceId, { backup: ["read"] }); + } else { + // Web server backups hold the whole instance, so they stay admin-only. + const member = await findMemberByUserId( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (member.role !== "owner" && member.role !== "admin") { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this backup.", + }); + } + } + + const destination = await findDestinationById(backup.destinationId); + if (destination.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this destination.", + }); + } + + return { destination, folder: getBackupFolder(backup) }; +}; + +export const listRcloneFiles = async ( + destination: Destination, + key: string, +): Promise => { + const path = `:s3:${destination.bucket}/${key}`; + const command = `rclone lsjson ${getS3Credentials(destination).join(" ")} --files-only --no-mimetype ${quote([path])}`; + + try { + // A folder without retention outgrows exec's 1 MiB default in a few thousand backups. + const { stdout } = await execAsync(command, { + maxBuffer: 32 * 1024 * 1024, + }); + + const files = JSON.parse(stdout) as RcloneFile[]; + return files.sort((a, b) => b.Name.localeCompare(a.Name)); + } catch (error) { + // Only stderr: the exec message carries the command, and redaction upstream + // does not cover the unquoted values shell-quote produces. + const detail = + error instanceof ExecError ? (error.stderr ?? "") : String(error); + + // A destination without backups yet is a normal state, not an error. + if (/directory not found|object not found/i.test(detail)) { + return []; + } + + console.error(redactRcloneCredentials(detail)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error listing backup files", + }); + } +}; diff --git a/packages/server/src/db/schema/audit-log.ts b/packages/server/src/db/schema/audit-log.ts index a1cec1910..ccbcc23dd 100644 --- a/packages/server/src/db/schema/audit-log.ts +++ b/packages/server/src/db/schema/audit-log.ts @@ -54,6 +54,7 @@ export type AuditAction = | "login" | "logout" | "restore" + | "download" | "run" | "start" | "stop" diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index d0d304558..38c560fa6 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -12,7 +12,7 @@ import { cleanupAll } from "../docker/utils"; import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { redactRcloneCredentials } from "./redact"; -import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils"; +import { getBackupFolder, getS3Credentials, scheduleBackup } from "./utils"; export const initCronJobs = async () => { console.log("Setting up cron jobs...."); @@ -109,21 +109,6 @@ export const initCronJobs = async () => { } }; -const getServiceAppName = (backup: BackupSchedule): string => { - if (backup.compose?.appName) { - return backup.serviceName - ? `${backup.compose.appName}_${backup.serviceName}` - : backup.compose.appName; - } - const serviceAppName = - backup.postgres?.appName || - backup.mysql?.appName || - backup.mariadb?.appName || - backup.mongo?.appName || - backup.libsql?.appName; - return serviceAppName || backup.appName; -}; - export const keepLatestNBackups = async ( backup: BackupSchedule, serverId?: string | null, @@ -135,8 +120,7 @@ export const keepLatestNBackups = async ( try { const destination = await findDestinationById(backup.destinationId); const rcloneFlags = getS3Credentials(destination); - const appName = getServiceAppName(backup); - const backupFilesPath = `:s3:${destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`; + const backupFilesPath = `:s3:${destination.bucket}/${getBackupFolder(backup)}`; // --include "*.bson.gz" or "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" ${backupFilesPath}`; diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 4253a3876..184803f51 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -303,3 +303,17 @@ export const getBackupCommand = ( echo "Backup done ✅" >> ${logPath}; `; }; + +export const getBackupFolder = (backup: BackupSchedule) => { + const appName = backup.compose?.appName + ? backup.serviceName + ? `${backup.compose.appName}_${backup.serviceName}` + : backup.compose.appName + : backup.postgres?.appName || + backup.mysql?.appName || + backup.mariadb?.appName || + backup.mongo?.appName || + backup.libsql?.appName || + backup.appName; + return `${appName}/${normalizeS3Path(backup.prefix)}`; +}; diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts index 37b0b7f91..9c4c03831 100644 --- a/packages/server/src/utils/process/execAsync.ts +++ b/packages/server/src/utils/process/execAsync.ts @@ -1,4 +1,5 @@ -import { exec, execFile } from "node:child_process"; +import { exec, execFile, spawn } from "node:child_process"; +import type { Readable } from "node:stream"; import util from "node:util"; import { findServerById } from "@dokploy/server/services/server"; import { Client } from "ssh2"; @@ -25,7 +26,12 @@ const execAsyncBase = util.promisify(exec); export const execAsync = async ( command: string, - options?: { cwd?: string; env?: NodeJS.ProcessEnv; shell?: string }, + options?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + shell?: string; + maxBuffer?: number; + }, ): Promise<{ stdout: string; stderr: string }> => { try { const result = await execAsyncBase(command, options); @@ -321,6 +327,37 @@ export const writeFileRemote = async ( }); }; +const STDERR_LIMIT = 8 * 1024; + +export const execStreamLocal = (command: string): Readable => { + const child = spawn(command, { shell: true }); + let stderr = ""; + + child.stderr.on("data", (data: Buffer) => { + stderr = `${stderr}${data.toString()}`.slice(-STDERR_LIMIT); + }); + child.on("error", (error) => child.stdout.emit("error", error)); + child.on("close", (code) => { + // Without a finished read the consumer cancelled, so this is not a real failure. + if (code !== 0 && child.stdout.readableEnded) { + child.stdout.emit( + "error", + new ExecError( + `Command failed with exit code ${code ?? "null"}: ${stderr}`, + { + command, + stderr, + exitCode: code ?? undefined, + }, + ), + ); + } + }); + child.stdout.on("close", () => child.kill()); + + return child.stdout; +}; + export const sleep = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); };