feat(backups): download backup files with presigned urls

This commit is contained in:
Narciso
2026-10-07 10:09:00 -04:00
parent 06a7559e8a
commit cf63d73623
12 changed files with 511 additions and 275 deletions
@@ -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" });
}
}
+17 -8
View 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",
});
}
};
+1
View File
@@ -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";
+179
View File
@@ -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",
});
}
};
+16 -11
View File
@@ -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 -33
View File
@@ -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));
};