* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * * SFTP uploader using the system sftp/scp binary with SSH key authentication. * * The private key is stored in the database (profile column) and written * to a temp file with 0600 permissions at upload time, then deleted. * This avoids leaving key files on the filesystem permanently. */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; class SftpUploader implements RemoteUploaderInterface { private string $host; private int $port; private string $username; private string $keyData; private string $passphrase; private string $password; private string $remotePath; public function __construct(object $profile) { $this->host = $profile->sftp_host ?? ''; $this->port = (int) ($profile->sftp_port ?? 22); $this->username = $profile->sftp_username ?? ''; $this->keyData = $profile->sftp_key_data ?? ''; $this->passphrase = $profile->sftp_passphrase ?? ''; $this->password = $profile->sftp_password ?? ''; $this->remotePath = rtrim($profile->sftp_path ?? '/backups', '/'); } public function upload(string $localPath, string $remoteName): array { if (empty($this->host)) { return ['success' => false, 'message' => 'SFTP host is not configured']; } if (empty($this->username)) { return ['success' => false, 'message' => 'SFTP username is not configured']; } if (empty($this->keyData) && empty($this->password)) { return ['success' => false, 'message' => 'SFTP requires either a private key or password']; } $keyFile = null; try { /* Write key to temp file if using key auth */ if (!empty($this->keyData)) { $keyFile = $this->writeTempKey(); } /* Ensure remote directory exists */ $this->ensureRemoteDir($keyFile); /* Upload via scp */ $remoteTarget = $this->username . '@' . $this->host . ':' . $this->remotePath . '/' . $remoteName; $cmd = $this->buildScpCommand($localPath, $remoteTarget, $keyFile); $output = []; $exitCode = 0; exec($cmd . ' 2>&1', $output, $exitCode); if ($exitCode !== 0) { $errorMsg = implode("\n", $output); throw new \RuntimeException('scp failed (exit ' . $exitCode . '): ' . $errorMsg); } /* Verify upload by checking remote file size */ $remoteFile = $this->remotePath . '/' . $remoteName; $remoteSize = $this->getRemoteFileSize($remoteFile, $keyFile); $localSize = filesize($localPath); if ($remoteSize > 0 && $remoteSize !== $localSize) { throw new \RuntimeException( 'Size mismatch after upload: local=' . $localSize . ' remote=' . $remoteSize ); } return [ 'success' => true, 'message' => 'Uploaded via SFTP: ' . $remoteFile, 'remote_path' => $remoteFile, ]; } catch (\Throwable $e) { return ['success' => false, 'message' => 'SFTP upload failed: ' . $e->getMessage()]; } finally { $this->cleanupTempKey($keyFile); } } public function testConnection(): array { if (empty($this->host)) { return ['success' => false, 'message' => 'SFTP host is not configured']; } $keyFile = null; try { if (!empty($this->keyData)) { $keyFile = $this->writeTempKey(); } $cmd = $this->buildSshCommand('echo "MokoSuiteBackup connection test OK" && hostname', $keyFile); $output = []; $exitCode = 0; exec($cmd . ' 2>&1', $output, $exitCode); if ($exitCode !== 0) { return ['success' => false, 'message' => 'SSH connection failed: ' . implode(' ', $output)]; } return [ 'success' => true, 'message' => 'Connected to ' . $this->host . ' — ' . implode(' ', $output), ]; } catch (\Throwable $e) { return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()]; } finally { $this->cleanupTempKey($keyFile); } } /** * Write the private key from the database to a temp file with 0600 permissions. */ private function writeTempKey(): string { $tmpDir = sys_get_temp_dir(); $keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key'; /* Key is stored base64-encoded in the database — decode before writing */ $keyContent = base64_decode($this->keyData, true); if ($keyContent === false) { /* Fallback: might be raw PEM (legacy or paste) */ $keyContent = $this->keyData; } if (file_put_contents($keyFile, $keyContent) === false) { throw new \RuntimeException('Cannot write temporary SSH key file'); } chmod($keyFile, 0600); return $keyFile; } /** * Delete the temp key file. */ private function cleanupTempKey(?string $keyFile): void { if ($keyFile !== null && is_file($keyFile)) { unlink($keyFile); } } /** * Ensure the remote directory exists via ssh mkdir -p. */ private function ensureRemoteDir(?string $keyFile): void { $escapedPath = escapeshellarg($this->remotePath); $cmd = $this->buildSshCommand('mkdir -p ' . $escapedPath, $keyFile); $output = []; $exitCode = 0; exec($cmd . ' 2>&1', $output, $exitCode); /* mkdir -p exits 0 even if dir already exists, so only fail on non-zero */ if ($exitCode !== 0) { throw new \RuntimeException('Cannot create remote directory: ' . implode(' ', $output)); } } /** * Get remote file size via ssh stat. */ private function getRemoteFileSize(string $remotePath, ?string $keyFile): int { $escapedPath = escapeshellarg($remotePath); $cmd = $this->buildSshCommand('stat -c %s ' . $escapedPath . ' 2>/dev/null || echo -1', $keyFile); $output = []; exec($cmd . ' 2>&1', $output); $size = (int) trim(implode('', $output)); return $size > 0 ? $size : 0; } /** * Build an scp command string with proper SSH options. */ private function buildScpCommand(string $localPath, string $remoteTarget, ?string $keyFile): string { $parts = ['scp', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes']; if ($this->port !== 22) { $parts[] = '-P'; $parts[] = (string) $this->port; } if ($keyFile !== null) { $parts[] = '-i'; $parts[] = escapeshellarg($keyFile); } if (!empty($this->passphrase)) { /* scp doesn't natively support passphrase via CLI — requires ssh-agent or expect. For now, key files should be unencrypted or use ssh-agent. */ } $parts[] = escapeshellarg($localPath); $parts[] = escapeshellarg($remoteTarget); return implode(' ', $parts); } /** * Build an ssh command string for remote commands. */ private function buildSshCommand(string $remoteCmd, ?string $keyFile): string { $parts = ['ssh', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes']; if ($this->port !== 22) { $parts[] = '-p'; $parts[] = (string) $this->port; } if ($keyFile !== null) { $parts[] = '-i'; $parts[] = escapeshellarg($keyFile); } $parts[] = escapeshellarg($this->username . '@' . $this->host); $parts[] = escapeshellarg($remoteCmd); return implode(' ', $parts); } }