diff --git a/apps/dokploy/__test__/backups/backup-files.test.ts b/apps/dokploy/__test__/backups/backup-files.test.ts new file mode 100644 index 000000000..59dd64604 --- /dev/null +++ b/apps/dokploy/__test__/backups/backup-files.test.ts @@ -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()), + 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()), + 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[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(); + }); +}); diff --git a/apps/dokploy/components/dashboard/database/backups/show-backup-files.tsx b/apps/dokploy/components/dashboard/database/backups/show-backup-files.tsx index 45033258c..be8e22faa 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backup-files.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backup-files.tsx @@ -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(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 ( @@ -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 */} - { - 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 ? ( - - ) : ( - - )} - + {download.isPending && + download.variables?.fileName === file.Name ? ( + + ) : ( + + )} diff --git a/apps/dokploy/components/proprietary/audit-logs/columns.tsx b/apps/dokploy/components/proprietary/audit-logs/columns.tsx index 517335e29..52409009e 100644 --- a/apps/dokploy/components/proprietary/audit-logs/columns.tsx +++ b/apps/dokploy/components/proprietary/audit-logs/columns.tsx @@ -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 = { diff --git a/apps/dokploy/components/proprietary/audit-logs/data-table.tsx b/apps/dokploy/components/proprietary/audit-logs/data-table.tsx index fd0ba7746..f163df29b 100644 --- a/apps/dokploy/components/proprietary/audit-logs/data-table.tsx +++ b/apps/dokploy/components/proprietary/audit-logs/data-table.tsx @@ -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" diff --git a/apps/dokploy/pages/api/backups/download.ts b/apps/dokploy/pages/api/backups/download.ts deleted file mode 100644 index ead211904..000000000 --- a/apps/dokploy/pages/api/backups/download.ts +++ /dev/null @@ -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" }); - } -} diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index 1d5f56144..692911c70 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -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 diff --git a/apps/dokploy/server/api/routers/proprietary/audit-log.ts b/apps/dokploy/server/api/routers/proprietary/audit-log.ts index 3ff814f1c..18c223a70 100644 --- a/apps/dokploy/server/api/routers/proprietary/audit-log.ts +++ b/apps/dokploy/server/api/routers/proprietary/audit-log.ts @@ -31,6 +31,7 @@ export const auditLogRouter = createTRPCRouter({ "redeploy", "login", "logout", + "download", ]) .optional(), resourceType: z diff --git a/apps/dokploy/server/api/utils/backup-files.ts b/apps/dokploy/server/api/utils/backup-files.ts deleted file mode 100644 index ad515a130..000000000 --- a/apps/dokploy/server/api/utils/backup-files.ts +++ /dev/null @@ -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 => { - 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/index.ts b/packages/server/src/index.ts index ad862de46..7cfe0688e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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"; diff --git a/packages/server/src/utils/backups/files.ts b/packages/server/src/utils/backups/files.ts new file mode 100644 index 000000000..220b1d244 --- /dev/null +++ b/packages/server/src/utils/backups/files.ts @@ -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 => { + 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", + }); + } +}; diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 184803f51..77c77150c 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -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)}`; diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts index 9c4c03831..f5a8b5d84 100644 --- a/packages/server/src/utils/process/execAsync.ts +++ b/packages/server/src/utils/process/execAsync.ts @@ -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)); };