Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions fix_issue_168.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Looking at this issue, I need to add support for multiple backup destination types (FTP, SFTP, Google Drive, S3, etc.) using rclone as the underlying transport layer. Let me analyze the repo structure and write a comprehensive solution.

```json
{
"solution_code": "# === packages/server/src/utils/backups/rclone.ts ===\nimport { execSync, spawn } from \"child_process\";\nimport { writeFileSync, unlinkSync, mkdirSync, existsSync } from \"fs\";\nimport { join } from \"path\";\nimport { tmpdir } from \"os\";\n\nexport type RcloneDestinationType =\n | \"s3\"\n | \"gcs\"\n | \"ftp\"\n | \"sftp\"\n | \"gdrive\"\n | \"onedrive\"\n | \"dropbox\"\n | \"b2\"\n | \"azure\";\n\nexport interface S3Config {\n type: \"s3\";\n accessKeyId: string;\n secretAccessKey: string;\n region: string;\n bucket: string;\n endpoint?: string;\n prefix?: string;\n}\n\nexport interface GCSConfig {\n type: \"gcs\";\n serviceAccountKey: string; // JSON string\n bucket: string;\n prefix?: string;\n}\n\nexport interface FTPConfig {\n type: \"ftp\";\n host: string;\n port: number;\n user: string;\n password: string;\n remotePath: string;\n tls?: boolean;\n}\n\nexport interface SFTPConfig {\n type: \"sftp\";\n host: string;\n port: number;\n user: string;\n password?: string;\n privateKey?: string;\n remotePath: string;\n}\n\nexport interface GDriveConfig {\n type: \"gdrive\";\n clientId: string;\n clientSecret: string;\n token: string; // JSON token string\n folderId?: string;\n remotePath?: string;\n}\n\nexport interface OneDriveConfig {\n type: \"onedrive\";\n clientId: string;\n clientSecret: string;\n token: string;\n driveId?: string;\n remotePath?: string;\n}\n\nexport interface DropboxConfig {\n type: \"dropbox\";\n appKey: string;\n appSecret: string;\n token: string;\n remotePath?: string;\n}\n\nexport interface B2Config {\n type: \"b2\";\n accountId: string;\n applicationKey: string;\n bucket: string;\n prefix?: string;\n}\n\nexport interface AzureConfig {\n type: \"azure\";\n account: string;\n key: string;\n container: string;\n prefix?: string;\n}\n\nexport type DestinationConfig =\n | S3Config\n | GCSConfig\n | FTPConfig\n | SFTPConfig\n | GDriveConfig\n | OneDriveConfig\n | DropboxConfig\n | B2Config\n | AzureConfig;\n\nfunction buildRcloneConfig(remoteName: string, config: DestinationConfig): string {\n switch (config.type) {\n case \"s3\":\n return `[${remoteName}]\\ntype = s3\\nprovider = ${config.endpoint ? \"Other\" : \"AWS\"}\\naccess_key_id = ${config.accessKeyId}\\nsecret_access_key = ${config.secretAccessKey}\\nregion = ${config.region}${config.endpoint ? `\\nendpoint = ${config.endpoint}` : \"\"}\\n`;\n\n case \"gcs\":\n const gcsKeyPath = join(tmpdir(), `gcs-key-${Date.now()}.json`);\n writeFileSync(gcsKeyPath, config.serviceAccountKey);\n return `[${remoteName}]\\ntype = google cloud storage\\nservice_account_file = ${gcsKeyPath}\\nobject_acl = private\\nbucket_acl = private\\n`;\n\n case \"ftp\":\n return `[${remoteName}]\\ntype = ftp\\nhost = ${config.host}\\nport = ${config.port}\\nuser = ${config.user}\\npass = ${config.password}\\ntls = ${config.tls ? \"true\" : \"false\"}\\n`;\n\n case \"sftp\":\n let sftpConf = `[${remoteName}]\\ntype = sftp\\nhost = ${config.host}\\nport = ${config.port}\\nuser = ${config.user}\\n`;\n if (config.password) {\n sftpConf += `pass = ${config.password}\\n`;\n }\n if (config.privateKey) {\n const keyPath = join(tmpdir(), `sftp-key-${Date.now()}`);\n writeFileSync(keyPath, config.privateKey, { mode: 0o600 });\n sftpConf += `key_file = ${keyPath}\\n`;\n }\n return sftpConf;\n\n case \"gdrive\":\n return `[${remoteName}]\\ntype = drive\\nclient_id = ${config.clientId}\\nclient_secret = ${config.clientSecret}\\ntoken = ${config.token}\\nscope = drive\\n${config.folderId ? `root_folder_id = ${config.folderId}\\n` : \"\"}`; \n\n case \"onedrive\":\n return `[${remoteName}]\\ntype = onedrive\\nclient_id = ${config.clientId}\\nclient_secret = ${config.clientSecret}\\ntoken = ${config.token}\\n${config.driveId ? `drive_id = ${config.driveId}\\ndrive_type = personal\\n` : \"\"}`; \n\n case \"dropbox\":\n return `[${remoteName}]\\ntype = dropbox\\napp_key = ${config.appKey}\\napp_secret = ${config.appSecret}\\ntoken = ${config.token}\\n`;\n\n case \"b2\":\n return `[${remoteName}]\\ntype = b2\\naccount = ${config.accountId}\\nkey = ${config.applicationKey}\\n`;\n\n case \"azure\":\n return `[${remoteName}]\\ntype = azureblob\\naccount = ${config.account}\\nkey = ${config.key}\\n`;\n\n default:\n throw new Error(`Unsupported destination type`);\n }\n}\n\nfunction getRemotePath(remoteName: string, config: DestinationConfig): string {\n switch (config.type) {\n case \"s3\":\n return `${remoteName}:${config.bucket}${config.prefix ? \"/\" + config.prefix : \"\"}`;\n case \"gcs\":\n return `${remoteName}:${config.bucket}${config.prefix ? \"/\" + config.prefix : \"\"}`;\n case \"ftp\":\n return `${remoteName}:${config.remotePath}`;\n case \"sftp\":\n return `${remoteName}:${config.remotePath}`;\n case \"gdrive\":\n return `${remoteName}:${config.remotePath || \"backups\"}`;\n case \"onedrive\":\n return `${remoteName}:${config.remotePath || \"backups\"}`;\n case \"dropbox\":\n return `${remoteName}:${config.remotePath || \"backups\"}`;\n case \"b2\":\n return `${remoteName}:${config.bucket}${config.prefix ? \"/\" + config.prefix : \"\"}`;\n case \"azure\":\n return `${remoteName}:${config.container}${config.prefix ? \"/\" + config.prefix : \"\"}`;\n default:\n throw new Error(`Unsupported destination type`);\n }\n}\n\nexport async function uploadWithRclone(\n localFilePath: string,\n destinationFileName: string,\n config: DestinationConfig\n): Promise<void> {\n const remoteName = `dokploy-backup-${Date.now()}`;\n const configPath = join(tmpdir(), `rclone-${remoteName}.conf`);\n\n try {\n // Write rclone config\n const rcloneConfig = buildRcloneConfig(remoteName, config);\n writeFileSync(configPath, rcloneConfig, { mode: 0o600 });\n\n const remotePath = getRemotePath(remoteName, config);\n const destination = `${remotePath}/${destinationFileName}`;\n\n await new Promise<void>((resolve, reject) => {\n const proc = spawn(\"rclone\", [\n \"copyto\",\n localFilePath,\n destination,\n `--config=${configPath}`,\n \"--progress\",\n \"--stats-one-line\",\n ]);\n\n let stderr = \"\";\n proc.stderr.on(\"data\", (data) => {\n stderr += data.toString();\n });\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve();\n } else {\n reject(new Error(`rclone exited with code ${code}: ${stderr}`));\n }\n });\n\n proc.on(\"error\", (err) => {\n reject(new Error(`Failed to spawn rclone: ${err.message}`));\n });\n });\n } finally {\n // Cleanup temp config\n try { unlinkSync(configPath); } catch {}\n }\n}\n\nexport async function testRcloneConnection(config: DestinationConfig): Promise<boolean> {\n const remoteName = `dokploy-test-${Date.now()}`;\n const configPath = join(tmpdir(), `rclone-${remoteName}.conf`);\n\n try {\n const rcloneConfig = buildRcloneConfig(remoteName, config);\n writeFileSync(configPath, rcloneConfig, { mode: 0o600 });\n const remotePath = getRemotePath(remoteName, config);\n\n await new Promise<void>((resolve, reject) => {\n const proc = spawn(\"rclone\", [\n \"lsd\",\n remotePath,\n `--config=${configPath}`,\n \"--max-depth\",\n \"1\",\n ]);\n\n proc.on(\"close\", (code) => {\n if (code === 0) resolve();\n else reject(new Error(`rclone lsd failed with code ${code}`));\n });\n proc.on(\"error\", reject);\n });\n return true;\n } catch {\n return false;\n } finally {\n try { unlinkSync(configPath); } catch {}\n }\n}\n\n\n# === packages/server/src/services/destination.ts ===\nimport { db } from \"../db\";\nimport { destinations } from \"../db/schema\";\nimport { eq } from \"drizzle-orm\";\nimport type { DestinationConfig } from \"../utils/backups/rclone\";\n\nexport const findDestinationById = async (destinationId: string) => {\n const destination = await db.query.destinations.findFirst({\n where: eq(destinations.destinationId, destinationId),\n })