feat: allow download backups

This commit is contained in:
Narciso
2026-08-31 14:37:24 -04:00
parent c225188ed4
commit 79557cb971
9 changed files with 425 additions and 32 deletions
@@ -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<string | null>(null);
const {
data: files,
isLoading,
isError,
error,
} = api.backup.listBackupFilesByBackupId.useQuery(
{ backupId },
{ enabled: isOpen },
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<FolderOpen className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Backup Files</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Backup Files</DialogTitle>
<DialogDescription>
Files stored in the destination for this backup.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error.message}</AlertBlock>}
{isLoading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && !isError && files?.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<DatabaseBackup className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No backup files found yet
</span>
</div>
)}
{!isError && !!files?.length && (
<ScrollArea className="max-h-[60vh]">
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Date</TableHead>
<TableHead>Size</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.Name}>
<TableCell className="font-medium break-all">
{file.Name}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{file.ModTime
? new Date(file.ModTime).toLocaleString()
: "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatBytes(file.Size)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="size-8"
asChild
>
{/* NextTopLoader starts on any anchor click and only stops on a route change */}
<a
href={`/api/backups/download?backupId=${encodeURIComponent(backupId)}&file=${encodeURIComponent(file.Name)}`}
download
onClick={(e) => {
e.stopPropagation();
setStartingDownload(file.Name);
// the browser owns the transfer, so this only acknowledges the click
setTimeout(
() =>
setStartingDownload((current) =>
current === file.Name ? null : current,
),
2500,
);
}}
>
{startingDownload === file.Name ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
</a>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
};
@@ -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 = ({
</Tooltip>
</TooltipProvider>
<ShowBackupFiles backupId={backup.backupId} />
<HandleBackup
backupType={backup.backupType}
backupId={backup.backupId}
@@ -0,0 +1,93 @@
import { validateRequest } from "@dokploy/server";
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { execStreamLocal } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
import type { NextApiRequest, NextApiResponse } from "next";
import { quote } from "shell-quote";
import { audit } from "@/server/api/utils/audit";
import {
listRcloneFiles,
resolveBackupAccess,
} from "@/server/api/utils/backup-files";
// externalResolver: the handler returns while the stream is still writing.
export const config = {
api: { responseLimit: false, externalResolver: true },
};
const FILE_NAME_REGEX = /^[A-Za-z0-9._-]+$/;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const { backupId, file } = req.query as { backupId?: string; file?: string };
if (
!backupId ||
!file ||
file.startsWith(".") ||
!FILE_NAME_REGEX.test(file)
) {
return res.status(400).json({ error: "Invalid request" });
}
const { user, session } = await validateRequest(req);
if (!user || !session?.activeOrganizationId) {
return res.status(401).json({ error: "Unauthorized" });
}
const ctx = {
user: { id: user.id, email: user.email, role: user.role },
session: { activeOrganizationId: session.activeOrganizationId },
};
try {
const { destination, folder } = await resolveBackupAccess(ctx, backupId);
// Listing the exact key avoids paging a folder that can hold thousands of backups.
const [backupFile] = await listRcloneFiles(destination, `${folder}${file}`);
if (backupFile?.Name !== file) {
return res.status(404).json({ error: "Backup file not found" });
}
await audit(ctx, {
action: "download",
resourceType: "backup",
resourceId: backupId,
metadata: { file },
});
const path = `:s3:${destination.bucket}/${folder}${file}`;
const command = `rclone cat ${getS3Credentials(destination).join(" ")} ${quote([path])}`;
if (res.destroyed) return;
const stream = execStreamLocal(command);
res.on("close", () => 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" });
}
}
+15 -12
View File
@@ -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: {
@@ -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<RcloneFile[]> => {
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",
});
}
};
@@ -54,6 +54,7 @@ export type AuditAction =
| "login"
| "logout"
| "restore"
| "download"
| "run"
| "start"
| "stop"
+2 -18
View File
@@ -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}`;
@@ -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)}`;
};
+39 -2
View File
@@ -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));
};