diff --git a/fix_issue_168.py b/fix_issue_168.py new file mode 100644 index 0000000000..171e15f494 --- /dev/null +++ b/fix_issue_168.py @@ -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 {\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((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 {\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((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 }) \ No newline at end of file