mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-20 15:35:23 +00:00
feat: allow download backups
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user