mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
feat(backups): download backup files with presigned urls
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import { ExecError } from "@dokploy/server/utils/process/execAsync";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findBackupById: vi.fn(),
|
||||
findDestinationById: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
findMemberByUserId: vi.fn(),
|
||||
checkServicePermissionAndAccess: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/backup", () => ({
|
||||
findBackupById: mocks.findBackupById,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/destination", () => ({
|
||||
findDestinationById: mocks.findDestinationById,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
findMemberByUserId: mocks.findMemberByUserId,
|
||||
checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess,
|
||||
}));
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
const baseBackup = {
|
||||
backupId: "backup-1",
|
||||
destinationId: "dest-1",
|
||||
databaseType: "postgres",
|
||||
prefix: "db",
|
||||
appName: "web",
|
||||
postgresId: "pg-1",
|
||||
postgres: { appName: "pg-app", serverId: null },
|
||||
};
|
||||
|
||||
const destination = {
|
||||
destinationId: "dest-1",
|
||||
organizationId: "org-1",
|
||||
bucket: "bucket",
|
||||
accessKey: "ak",
|
||||
secretAccessKey: "sk",
|
||||
region: "us-east-1",
|
||||
endpoint: "http://s3.local",
|
||||
provider: "Minio",
|
||||
additionalFlags: [],
|
||||
};
|
||||
|
||||
const loadModule = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
IS_CLOUD: isCloud,
|
||||
}));
|
||||
return await import("@dokploy/server/utils/backups/files");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findBackupById.mockResolvedValue(baseBackup);
|
||||
mocks.findDestinationById.mockResolvedValue(destination);
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-1",
|
||||
});
|
||||
mocks.findMemberByUserId.mockResolvedValue({ role: "owner" });
|
||||
mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("getBackupServerId", () => {
|
||||
it("returns the serverId of the backed up service", async () => {
|
||||
const { getBackupServerId } = await loadModule(false);
|
||||
const backup = baseBackup as Parameters<typeof getBackupServerId>[0];
|
||||
expect(getBackupServerId(backup)).toBeNull();
|
||||
for (const type of [
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"libsql",
|
||||
"compose",
|
||||
]) {
|
||||
expect(
|
||||
getBackupServerId({ ...backup, [type]: { serverId: `srv-${type}` } }),
|
||||
).toBe(`srv-${type}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBackupAccess", () => {
|
||||
it("rejects backups without a server on cloud", async () => {
|
||||
const { resolveBackupAccess } = await loadModule(true);
|
||||
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Server not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a destination from another organization", async () => {
|
||||
const { resolveBackupAccess } = await loadModule(false);
|
||||
mocks.findDestinationById.mockResolvedValue({
|
||||
...destination,
|
||||
organizationId: "org-2",
|
||||
});
|
||||
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a server from another organization", async () => {
|
||||
const { resolveBackupAccess } = await loadModule(false);
|
||||
mocks.findBackupById.mockResolvedValue({
|
||||
...baseBackup,
|
||||
postgres: { appName: "pg-app", serverId: "server-1" },
|
||||
});
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-2",
|
||||
});
|
||||
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this server.",
|
||||
});
|
||||
});
|
||||
|
||||
it("only lets owners and admins reach web-server backups", async () => {
|
||||
const { resolveBackupAccess } = await loadModule(false);
|
||||
mocks.findBackupById.mockResolvedValue({
|
||||
...baseBackup,
|
||||
databaseType: "web-server",
|
||||
postgresId: null,
|
||||
postgres: null,
|
||||
});
|
||||
mocks.findMemberByUserId.mockResolvedValue({ role: "member" });
|
||||
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
mocks.findMemberByUserId.mockResolvedValue({ role: "admin" });
|
||||
await expect(resolveBackupAccess(ctx, "backup-1")).resolves.toMatchObject({
|
||||
serverId: null,
|
||||
path: ":s3:bucket/web/db/",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listBackupFilesByBackupId", () => {
|
||||
it("lists on the service server sorted by ModTime", async () => {
|
||||
const { listBackupFilesByBackupId } = await loadModule(false);
|
||||
mocks.findBackupById.mockResolvedValue({
|
||||
...baseBackup,
|
||||
postgres: { appName: "pg-app", serverId: "server-1" },
|
||||
});
|
||||
mocks.execAsyncRemote.mockResolvedValue({
|
||||
stdout: JSON.stringify([
|
||||
{ Name: "first.sql.gz", ModTime: "2026-01-01T00:00:00Z" },
|
||||
{ Name: "third.sql.gz", ModTime: "2026-01-01T04:00:00+03:00" },
|
||||
{ Name: "second.sql.gz", ModTime: "2026-01-01T00:30:00Z" },
|
||||
]),
|
||||
stderr: "",
|
||||
});
|
||||
const files = await listBackupFilesByBackupId(ctx, "backup-1");
|
||||
expect(files.map((file) => file.Name)).toEqual([
|
||||
"third.sql.gz",
|
||||
"second.sql.gz",
|
||||
"first.sql.gz",
|
||||
]);
|
||||
expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
"pg-1",
|
||||
{ backup: ["read"] },
|
||||
);
|
||||
const [serverId, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(serverId).toBe("server-1");
|
||||
expect(command.replace(/\\/g, "")).toContain(
|
||||
"--use-server-modtime --include *.{sql.gz,bson.gz} :s3:bucket/pg-app/db/",
|
||||
);
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an empty list when the folder does not exist", async () => {
|
||||
const { listBackupFilesByBackupId } = await loadModule(false);
|
||||
mocks.execAsync.mockRejectedValue(
|
||||
new ExecError("Command execution failed", {
|
||||
command: "rclone",
|
||||
stderr: "ERROR : directory not found",
|
||||
}),
|
||||
);
|
||||
await expect(listBackupFilesByBackupId(ctx, "backup-1")).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBackupDownloadUrl", () => {
|
||||
it.each(["../x.sql.gz", "a/b.sql.gz", ".hidden.sql.gz", "x.txt"])(
|
||||
"rejects %s",
|
||||
async (fileName) => {
|
||||
const { getBackupDownloadUrl } = await loadModule(false);
|
||||
await expect(
|
||||
getBackupDownloadUrl(ctx, "backup-1", fileName),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("returns the presigned url from rclone link", async () => {
|
||||
const { getBackupDownloadUrl } = await loadModule(false);
|
||||
mocks.execAsync.mockResolvedValue({
|
||||
stdout: "https://s3.local/bucket/pg-app/db/x.sql.gz?X-Amz-Signature=1\n",
|
||||
stderr: "",
|
||||
});
|
||||
await expect(
|
||||
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
|
||||
).resolves.toEqual({
|
||||
url: "https://s3.local/bucket/pg-app/db/x.sql.gz?X-Amz-Signature=1",
|
||||
});
|
||||
const [command] = mocks.execAsync.mock.calls[0] ?? [];
|
||||
expect(command).toContain("rclone link --expire 15m");
|
||||
expect(command.replace(/\\/g, "")).toContain(
|
||||
":s3:bucket/pg-app/db/x.sql.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps object not found to NOT_FOUND", async () => {
|
||||
const { getBackupDownloadUrl } = await loadModule(false);
|
||||
mocks.execAsync.mockRejectedValue(
|
||||
new ExecError("Command execution failed", {
|
||||
command: "rclone",
|
||||
stderr: "ERROR : object not found",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("never logs the command on other failures", async () => {
|
||||
const { getBackupDownloadUrl } = await loadModule(false);
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
mocks.execAsync.mockRejectedValue(
|
||||
new ExecError(
|
||||
"Command failed: rclone link --s3-secret-access-key=SUPERSECRET",
|
||||
{
|
||||
command: "rclone link --s3-secret-access-key=SUPERSECRET",
|
||||
stderr: "ERROR : AccessDenied",
|
||||
},
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(JSON.stringify(consoleError.mock.calls)).not.toContain(
|
||||
"SUPERSECRET",
|
||||
);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DatabaseBackup, Download, FolderOpen, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -34,7 +35,6 @@ interface Props {
|
||||
|
||||
export const ShowBackupFiles = ({ backupId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [startingDownload, setStartingDownload] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: files,
|
||||
@@ -46,6 +46,11 @@ export const ShowBackupFiles = ({ backupId }: Props) => {
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
const download = api.backup.getDownloadUrl.useMutation({
|
||||
onSuccess: ({ url }) => window.open(url, "_blank", "noopener"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -116,31 +121,17 @@ export const ShowBackupFiles = ({ backupId }: Props) => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
asChild
|
||||
disabled={download.isPending}
|
||||
onClick={() =>
|
||||
download.mutate({ backupId, fileName: file.Name })
|
||||
}
|
||||
>
|
||||
{/* 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>
|
||||
{download.isPending &&
|
||||
download.variables?.fileName === file.Name ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Download,
|
||||
FileJson,
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -77,6 +78,11 @@ const ACTION_CONFIG: Record<
|
||||
className:
|
||||
"bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
|
||||
},
|
||||
download: {
|
||||
label: "Downloaded",
|
||||
icon: Download,
|
||||
className: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
const RESOURCE_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -53,6 +53,7 @@ const ACTION_OPTIONS = [
|
||||
{ value: "redeploy", label: "Redeployed" },
|
||||
{ value: "login", label: "Login" },
|
||||
{ value: "logout", label: "Logout" },
|
||||
{ value: "download", label: "Downloaded" },
|
||||
];
|
||||
|
||||
const RESOURCE_OPTIONS = [
|
||||
@@ -83,7 +84,8 @@ type AuditAction =
|
||||
| "cancel"
|
||||
| "redeploy"
|
||||
| "login"
|
||||
| "logout";
|
||||
| "logout"
|
||||
| "download";
|
||||
type AuditResourceType =
|
||||
| "project"
|
||||
| "service"
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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,8 +15,11 @@ import {
|
||||
findPostgresByBackupId,
|
||||
findPostgresById,
|
||||
findServerById,
|
||||
getBackupDownloadUrl,
|
||||
IS_CLOUD,
|
||||
keepLatestNBackups,
|
||||
listBackupFilesByBackupId,
|
||||
type RcloneFile,
|
||||
removeBackupById,
|
||||
removeScheduleBackup,
|
||||
runLibsqlBackup,
|
||||
@@ -57,11 +60,6 @@ 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,
|
||||
@@ -558,12 +556,23 @@ export const backupRouter = createTRPCRouter({
|
||||
|
||||
listBackupFilesByBackupId: withPermission("backup", "read")
|
||||
.input(z.object({ backupId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { destination, folder } = await resolveBackupAccess(
|
||||
.query(({ input, ctx }) => listBackupFilesByBackupId(ctx, input.backupId)),
|
||||
|
||||
getDownloadUrl: withPermission("backup", "read")
|
||||
.input(z.object({ backupId: z.string(), fileName: z.string().min(1) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const result = await getBackupDownloadUrl(
|
||||
ctx,
|
||||
input.backupId,
|
||||
input.fileName,
|
||||
);
|
||||
return await listRcloneFiles(destination, folder);
|
||||
await audit(ctx, {
|
||||
action: "download",
|
||||
resourceType: "backup",
|
||||
resourceId: input.backupId,
|
||||
metadata: { file: input.fileName },
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
restoreBackupWithLogs: protectedProcedure
|
||||
|
||||
@@ -31,6 +31,7 @@ export const auditLogRouter = createTRPCRouter({
|
||||
"redeploy",
|
||||
"login",
|
||||
"logout",
|
||||
"download",
|
||||
])
|
||||
.optional(),
|
||||
resourceType: z
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -79,6 +79,7 @@ export {
|
||||
export * from "./utils/access-log/types";
|
||||
export * from "./utils/access-log/utils";
|
||||
export * from "./utils/backups/compose";
|
||||
export * from "./utils/backups/files";
|
||||
export * from "./utils/backups/index";
|
||||
export * from "./utils/backups/libsql";
|
||||
export * from "./utils/backups/mariadb";
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import {
|
||||
type BackupSchedule,
|
||||
findBackupById,
|
||||
} from "@dokploy/server/services/backup";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import {
|
||||
checkServicePermissionAndAccess,
|
||||
findMemberByUserId,
|
||||
type PermissionCtx,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { findServerById } from "@dokploy/server/services/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import { ExecError, execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { getBackupFolder, getS3Credentials } from "./utils";
|
||||
|
||||
export interface RcloneFile {
|
||||
Path: string;
|
||||
Name: string;
|
||||
Size: number;
|
||||
IsDir: boolean;
|
||||
ModTime?: string;
|
||||
Tier?: string;
|
||||
Hashes?: {
|
||||
MD5?: string;
|
||||
SHA1?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const BACKUP_FILE_NAME = /^[^/.][^/]*\.(sql\.gz|bson\.gz|zip)$/;
|
||||
|
||||
export const getBackupServerId = (backup: BackupSchedule) =>
|
||||
backup.postgres?.serverId ??
|
||||
backup.mysql?.serverId ??
|
||||
backup.mariadb?.serverId ??
|
||||
backup.mongo?.serverId ??
|
||||
backup.libsql?.serverId ??
|
||||
backup.compose?.serverId ??
|
||||
null;
|
||||
|
||||
export const resolveBackupAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
backupId: string,
|
||||
) => {
|
||||
const backup = await findBackupById(backupId);
|
||||
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.",
|
||||
});
|
||||
}
|
||||
|
||||
const serviceId =
|
||||
backup.postgresId ||
|
||||
backup.mysqlId ||
|
||||
backup.mariadbId ||
|
||||
backup.mongoId ||
|
||||
backup.libsqlId ||
|
||||
backup.composeId;
|
||||
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, { backup: ["read"] });
|
||||
} else {
|
||||
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 serverId = getBackupServerId(backup);
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
if (server.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this server.",
|
||||
});
|
||||
}
|
||||
} else if (IS_CLOUD) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Server not found" });
|
||||
}
|
||||
|
||||
return {
|
||||
backup,
|
||||
destination,
|
||||
serverId,
|
||||
path: `:s3:${destination.bucket}/${getBackupFolder(backup)}`,
|
||||
};
|
||||
};
|
||||
|
||||
const runRclone = (command: string, serverId: string | null) =>
|
||||
serverId
|
||||
? execAsyncRemote(serverId, command)
|
||||
: execAsync(command, { maxBuffer: 32 * 1024 * 1024 });
|
||||
|
||||
const getErrorDetail = (error: unknown) =>
|
||||
error instanceof ExecError
|
||||
? error.stderr || (error.serverId ? error.message : "")
|
||||
: String(error);
|
||||
|
||||
const modTime = (file: RcloneFile) => Date.parse(file.ModTime ?? "") || 0;
|
||||
|
||||
export const listBackupFilesByBackupId = async (
|
||||
ctx: PermissionCtx,
|
||||
backupId: string,
|
||||
): Promise<RcloneFile[]> => {
|
||||
const { backup, destination, serverId, path } = await resolveBackupAccess(
|
||||
ctx,
|
||||
backupId,
|
||||
);
|
||||
const include =
|
||||
backup.databaseType === "web-server" ? "*.zip" : "*.{sql.gz,bson.gz}";
|
||||
const command = `rclone lsjson ${getS3Credentials(destination).join(" ")} --files-only --no-mimetype --use-server-modtime --include ${quote([include])} ${quote([path])}`;
|
||||
|
||||
try {
|
||||
const { stdout } = await runRclone(command, serverId);
|
||||
const files = JSON.parse(stdout) as RcloneFile[];
|
||||
return files.sort((a, b) => modTime(b) - modTime(a));
|
||||
} catch (error) {
|
||||
const detail = getErrorDetail(error);
|
||||
if (/directory not found|object not found/i.test(detail)) {
|
||||
return [];
|
||||
}
|
||||
console.error("rclone lsjson failed:", redactRcloneCredentials(detail));
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error listing backup files",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getBackupDownloadUrl = async (
|
||||
ctx: PermissionCtx,
|
||||
backupId: string,
|
||||
fileName: string,
|
||||
) => {
|
||||
if (!BACKUP_FILE_NAME.test(fileName)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid backup file name",
|
||||
});
|
||||
}
|
||||
const { destination, serverId, path } = await resolveBackupAccess(
|
||||
ctx,
|
||||
backupId,
|
||||
);
|
||||
const command = `rclone link --expire 15m ${getS3Credentials(destination).join(" ")} ${quote([`${path}${fileName}`])}`;
|
||||
|
||||
try {
|
||||
const { stdout } = await runRclone(command, serverId);
|
||||
const url = stdout.trim().split("\n").pop();
|
||||
if (!url) {
|
||||
throw new Error("rclone link returned no URL");
|
||||
}
|
||||
return { url };
|
||||
} catch (error) {
|
||||
const detail = getErrorDetail(error);
|
||||
if (/object not found/i.test(detail)) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Backup file not found",
|
||||
});
|
||||
}
|
||||
console.error("rclone link failed:", redactRcloneCredentials(detail));
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Could not generate the download link",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -304,16 +304,21 @@ export const getBackupCommand = (
|
||||
`;
|
||||
};
|
||||
|
||||
export const getBackupFolder = (backup: BackupSchedule) => {
|
||||
const appName = backup.compose?.appName
|
||||
? backup.serviceName
|
||||
const getServiceAppName = (backup: BackupSchedule) => {
|
||||
if (backup.compose?.appName) {
|
||||
return 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)}`;
|
||||
: backup.compose.appName;
|
||||
}
|
||||
return (
|
||||
backup.postgres?.appName ||
|
||||
backup.mysql?.appName ||
|
||||
backup.mariadb?.appName ||
|
||||
backup.mongo?.appName ||
|
||||
backup.libsql?.appName ||
|
||||
backup.appName
|
||||
);
|
||||
};
|
||||
|
||||
export const getBackupFolder = (backup: BackupSchedule) =>
|
||||
`${getServiceAppName(backup)}/${normalizeS3Path(backup.prefix)}`;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { exec, execFile, spawn } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import util from "node:util";
|
||||
import { findServerById } from "@dokploy/server/services/server";
|
||||
import { Client } from "ssh2";
|
||||
@@ -327,37 +326,6 @@ 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