diff --git a/apps/dokploy/__test__/utils/backups.test.ts b/apps/dokploy/__test__/utils/backups.test.ts index 2c1e5decc9..94f51138d7 100644 --- a/apps/dokploy/__test__/utils/backups.test.ts +++ b/apps/dokploy/__test__/utils/backups.test.ts @@ -1,6 +1,31 @@ -import { normalizeS3Path } from "@dokploy/server/utils/backups/utils"; +import type { Destination } from "@dokploy/server/services/destination"; +import { + getRcloneDestination, + getRcloneDestinationProvider, + getRcloneFlags, + normalizeRclonePath, + normalizeS3Path, + shellQuote, +} from "@dokploy/server/utils/backups/utils"; import { describe, expect, test } from "vitest"; +const createDestination = ( + overrides: Partial = {}, +): Destination => ({ + destinationId: "destination", + name: "Backups", + provider: "AWS", + accessKey: "access-key", + secretAccessKey: "secret-key", + bucket: "dokploy-backups", + region: "us-east-1", + endpoint: "https://s3.example.com", + additionalFlags: [], + organizationId: "organization", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, +}); + describe("normalizeS3Path", () => { test("should handle empty and whitespace-only prefix", () => { expect(normalizeS3Path("")).toBe(""); @@ -59,3 +84,122 @@ describe("normalizeS3Path", () => { expect(normalizeS3Path("instance-backups")).toBe("instance-backups/"); }); }); + +describe("rclone destination providers", () => { + test("should keep existing S3 providers mapped to the s3 backend", () => { + const destination = createDestination({ provider: "Cloudflare" }); + + expect(getRcloneDestinationProvider(destination)).toBe("s3"); + expect(getRcloneDestination(destination, "app/prefix/backup.sql.gz")).toBe( + ":s3:dokploy-backups/app/prefix/backup.sql.gz", + ); + expect(getRcloneFlags(destination)).toEqual([ + "--s3-provider='Cloudflare'", + "--s3-access-key-id='access-key'", + "--s3-secret-access-key='secret-key'", + "--s3-region='us-east-1'", + "--s3-endpoint='https://s3.example.com'", + "--s3-no-check-bucket", + "--s3-force-path-style", + ]); + }); + + test("should build FTP flags and preserve absolute base paths", () => { + const destination = createDestination({ + provider: "ftp", + accessKey: "backup-user", + secretAccessKey: "pa'ss word", + bucket: "/srv/backups", + region: "2121", + endpoint: "ftp.example.com", + }); + + expect(getRcloneDestinationProvider(destination)).toBe("ftp"); + expect(getRcloneDestination(destination, "app/backup.sql.gz")).toBe( + ":ftp:/srv/backups/app/backup.sql.gz", + ); + expect(getRcloneFlags(destination)).toEqual([ + "--ftp-host='ftp.example.com'", + "--ftp-user='backup-user'", + "--ftp-port='2121'", + "--ftp-pass=$(rclone obscure 'pa'\\''ss word')", + ]); + }); + + test("should build SFTP flags with additional rclone options", () => { + const destination = createDestination({ + provider: "sftp", + accessKey: "dokploy", + secretAccessKey: "secret", + bucket: "relative/backups", + region: "", + endpoint: "sftp.example.com", + additionalFlags: ["--sftp-known-hosts-file=/root/.ssh/known_hosts"], + }); + + expect(getRcloneDestination(destination, "app/backup.sql.gz")).toBe( + ":sftp:relative/backups/app/backup.sql.gz", + ); + expect(getRcloneFlags(destination)).toEqual([ + "--sftp-host='sftp.example.com'", + "--sftp-user='dokploy'", + "--sftp-pass=$(rclone obscure 'secret')", + "--sftp-known-hosts-file=/root/.ssh/known_hosts", + ]); + }); + + test("should build Google Drive flags from token JSON", () => { + const token = + '{"access_token":"access","token_type":"Bearer","refresh_token":"refresh","expiry":"2026-01-01T00:00:00Z"}'; + const destination = createDestination({ + provider: "drive", + accessKey: "client-id", + secretAccessKey: "client-secret", + bucket: "Dokploy Backups", + region: "root-folder-id", + endpoint: token, + }); + + expect(getRcloneDestination(destination, "app/backup.sql.gz")).toBe( + ":drive:Dokploy Backups/app/backup.sql.gz", + ); + expect(getRcloneFlags(destination)).toEqual([ + "--drive-client-id='client-id'", + "--drive-client-secret='client-secret'", + `--drive-token=${shellQuote(token)}`, + "--drive-root-folder-id='root-folder-id'", + ]); + }); + + test("should build OneDrive flags without optional client credentials", () => { + const token = '{"access_token":"access","refresh_token":"refresh"}'; + const destination = createDestination({ + provider: "onedrive", + accessKey: "", + secretAccessKey: "", + bucket: "", + region: "drive-id", + endpoint: token, + }); + + expect(getRcloneDestination(destination, "app/backup.sql.gz")).toBe( + ":onedrive:app/backup.sql.gz", + ); + expect(getRcloneFlags(destination)).toEqual([ + `--onedrive-token=${shellQuote(token)}`, + "--onedrive-drive-id='drive-id'", + ]); + }); + + test("should normalize rclone paths while preserving FTP/SFTP root semantics", () => { + expect( + normalizeRclonePath("/absolute/path/", { preserveLeadingSlash: true }), + ).toBe("/absolute/path/"); + expect(normalizeRclonePath("/absolute/path/")).toBe("absolute/path/"); + }); + + test("should quote shell values safely", () => { + expect(shellQuote("path/with spaces")).toBe("'path/with spaces'"); + expect(shellQuote("value'with'quotes")).toBe("'value'\\''with'\\''quotes'"); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/destination/constants.ts b/apps/dokploy/components/dashboard/settings/destination/constants.ts index f43e47d1a1..99240cbc52 100644 --- a/apps/dokploy/components/dashboard/settings/destination/constants.ts +++ b/apps/dokploy/components/dashboard/settings/destination/constants.ts @@ -1,3 +1,38 @@ +export const RCLONE_DESTINATION_PROVIDERS = [ + { + key: "ftp", + name: "FTP", + }, + { + key: "sftp", + name: "SFTP", + }, + { + key: "drive", + name: "Google Drive", + }, + { + key: "onedrive", + name: "Microsoft OneDrive", + }, +] as const; + +const RCLONE_DESTINATION_PROVIDER_KEYS = new Set( + RCLONE_DESTINATION_PROVIDERS.map((provider) => provider.key), +); + +export const getDestinationProviderType = (provider: string) => { + const normalizedProvider = provider.trim().toLowerCase(); + if ( + RCLONE_DESTINATION_PROVIDER_KEYS.has( + normalizedProvider as (typeof RCLONE_DESTINATION_PROVIDERS)[number]["key"], + ) + ) { + return normalizedProvider as (typeof RCLONE_DESTINATION_PROVIDERS)[number]["key"]; + } + return "s3"; +}; + export const S3_PROVIDERS: Array<{ key: string; name: string; diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index e7ecf92b2b..6f0added93 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -37,33 +37,160 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; -import { S3_PROVIDERS } from "./constants"; +import { + getDestinationProviderType, + RCLONE_DESTINATION_PROVIDERS, + S3_PROVIDERS, +} from "./constants"; + +type DestinationFieldName = + | "accessKeyId" + | "secretAccessKey" + | "bucket" + | "region" + | "endpoint"; + +const addDestination = z + .object({ + name: z.string().min(1, "Name is required"), + provider: z.string().min(1, "Provider is required"), + accessKeyId: z.string(), + secretAccessKey: z.string(), + bucket: z.string(), + region: z.string(), + endpoint: z.string(), + serverId: z.string().optional(), + additionalFlags: z + .array( + z.object({ + value: z + .string() + .min(1, "Flag cannot be empty") + .regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR), + }), + ) + .optional(), + }) + .superRefine((data, ctx) => { + const providerType = getDestinationProviderType(data.provider); + const addRequiredIssue = (path: DestinationFieldName, message: string) => { + ctx.addIssue({ code: "custom", path: [path], message }); + }; -const addDestination = z.object({ - name: z.string().min(1, "Name is required"), - provider: z.string().min(1, "Provider is required"), - accessKeyId: z.string().min(1, "Access Key Id is required"), - secretAccessKey: z.string().min(1, "Secret Access Key is required"), - bucket: z.string().min(1, "Bucket is required"), - region: z.string(), - endpoint: z.string().min(1, "Endpoint is required"), - serverId: z.string().optional(), - additionalFlags: z - .array( - z.object({ - value: z - .string() - .min(1, "Flag cannot be empty") - .regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR), - }), - ) - .optional(), -}); + if (providerType === "s3") { + if (!data.accessKeyId.trim()) { + addRequiredIssue("accessKeyId", "Access Key Id is required"); + } + if (!data.secretAccessKey.trim()) { + addRequiredIssue("secretAccessKey", "Secret Access Key is required"); + } + if (!data.bucket.trim()) { + addRequiredIssue("bucket", "Bucket is required"); + } + if (!data.endpoint.trim()) { + addRequiredIssue("endpoint", "Endpoint is required"); + } + return; + } + + if (providerType === "ftp" || providerType === "sftp") { + if (!data.endpoint.trim()) { + addRequiredIssue("endpoint", "Host is required"); + } + if (!data.accessKeyId.trim()) { + addRequiredIssue("accessKeyId", "Username is required"); + } + if (!data.secretAccessKey.trim()) { + addRequiredIssue("secretAccessKey", "Password is required"); + } + if (data.region.trim()) { + const port = Number(data.region); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + addRequiredIssue("region", "Port must be between 1 and 65535"); + } + } + return; + } + + if (!data.endpoint.trim()) { + addRequiredIssue("endpoint", "OAuth token JSON is required"); + return; + } + + try { + JSON.parse(data.endpoint); + } catch { + addRequiredIssue("endpoint", "OAuth token must be valid JSON"); + } + }); type AddDestination = z.infer; +const getFieldLabels = (provider: string) => { + const providerType = getDestinationProviderType(provider); + if (providerType === "ftp" || providerType === "sftp") { + return { + namePlaceholder: `${providerType.toUpperCase()} Backups`, + accessKeyLabel: "Username", + accessKeyPlaceholder: "dokploy", + secretLabel: "Password", + secretPlaceholder: "password", + bucketLabel: "Base Path", + bucketPlaceholder: "/backups", + regionLabel: "Port", + regionPlaceholder: providerType === "ftp" ? "21" : "22", + endpointLabel: "Host", + endpointPlaceholder: "backup.example.com", + additionalFlagPlaceholder: + providerType === "ftp" + ? "--ftp-explicit-tls=true" + : "--sftp-known-hosts-file=/root/.ssh/known_hosts", + }; + } + if (providerType === "drive" || providerType === "onedrive") { + return { + namePlaceholder: + providerType === "drive" ? "Google Drive Backups" : "OneDrive Backups", + accessKeyLabel: "Client ID (Optional)", + accessKeyPlaceholder: "client-id", + secretLabel: "Client Secret (Optional)", + secretPlaceholder: "client-secret", + bucketLabel: "Base Folder", + bucketPlaceholder: "dokploy-backups", + regionLabel: + providerType === "drive" + ? "Root Folder ID (Optional)" + : "Drive ID (Optional)", + regionPlaceholder: + providerType === "drive" ? "root-folder-id" : "drive-id", + endpointLabel: "OAuth Token JSON", + endpointPlaceholder: + '{"access_token":"...","token_type":"Bearer","refresh_token":"...","expiry":"..."}', + additionalFlagPlaceholder: + providerType === "drive" + ? "--drive-scope=drive" + : "--onedrive-drive-type=business", + }; + } + return { + namePlaceholder: "S3 Bucket", + accessKeyLabel: "Access Key Id", + accessKeyPlaceholder: "xcas41dasde", + secretLabel: "Secret Access Key", + secretPlaceholder: "asd123asdasw", + bucketLabel: "Bucket", + bucketPlaceholder: "dokploy-bucket", + regionLabel: "Region", + regionPlaceholder: "us-east-1", + endpointLabel: "Endpoint", + endpointPlaceholder: "https://us.bucket.aws/s3", + additionalFlagPlaceholder: "--s3-sign-accept-encoding=false", + }; +}; + interface Props { destinationId?: string; } @@ -112,6 +239,9 @@ export const HandleDestinations = ({ destinationId }: Props) => { control: form.control, name: "additionalFlags", }); + const selectedProvider = form.watch("provider"); + const selectedProviderType = getDestinationProviderType(selectedProvider); + const fieldLabels = getFieldLabels(selectedProvider); useEffect(() => { if (destination) { @@ -161,12 +291,35 @@ export const HandleDestinations = ({ destinationId }: Props) => { }); }; + const handleProviderChange = (value: string) => { + const previousType = getDestinationProviderType(form.getValues("provider")); + const nextType = getDestinationProviderType(value); + form.setValue("provider", value); + if (previousType !== nextType) { + form.setValue("accessKeyId", ""); + form.setValue("secretAccessKey", ""); + form.setValue("bucket", ""); + form.setValue("region", ""); + form.setValue("endpoint", ""); + form.setValue("additionalFlags", []); + form.clearErrors([ + "accessKeyId", + "secretAccessKey", + "bucket", + "region", + "endpoint", + "additionalFlags", + ]); + } + }; + const handleTestConnection = async (serverId?: string) => { const result = await form.trigger([ "provider", "accessKeyId", "secretAccessKey", "bucket", + "region", "endpoint", "additionalFlags", ]); @@ -195,8 +348,11 @@ export const HandleDestinations = ({ destinationId }: Props) => { const bucket = form.getValues("bucket"); const endpoint = form.getValues("endpoint"); const region = form.getValues("region"); - - const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`; + const providerType = getDestinationProviderType(provider); + const connectionString = + providerType === "s3" + ? `:s3,provider=${provider},access_key_id=***,secret_access_key=***,endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}` + : `:${providerType}:${bucket}`; await testConnection({ provider, @@ -269,7 +425,10 @@ export const HandleDestinations = ({ destinationId }: Props) => { Name - + @@ -285,24 +444,38 @@ export const HandleDestinations = ({ destinationId }: Props) => { Provider @@ -318,9 +491,12 @@ export const HandleDestinations = ({ destinationId }: Props) => { render={({ field }) => { return ( - Access Key Id + {fieldLabels.accessKeyLabel} - + @@ -333,10 +509,13 @@ export const HandleDestinations = ({ destinationId }: Props) => { render={({ field }) => (
- Secret Access Key + {fieldLabels.secretLabel}
- +
@@ -348,10 +527,13 @@ export const HandleDestinations = ({ destinationId }: Props) => { render={({ field }) => (
- Bucket + {fieldLabels.bucketLabel}
- +
@@ -363,10 +545,13 @@ export const HandleDestinations = ({ destinationId }: Props) => { render={({ field }) => (
- Region + {fieldLabels.regionLabel}
- +
@@ -377,12 +562,21 @@ export const HandleDestinations = ({ destinationId }: Props) => { name="endpoint" render={({ field }) => ( - Endpoint + {fieldLabels.endpointLabel} - + {selectedProviderType === "drive" || + selectedProviderType === "onedrive" ? ( +