diff --git a/CHANGELOG.md b/CHANGELOG.md index 90773faa..7f0364d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ - Initial package structure with component, system plugin, task plugin, and webservices plugin - Joomla Scheduled Tasks integration (plg_task_mokobackup) — create multiple tasks, each running a different backup profile on its own schedule - Individual form fields for all profile settings (no raw JSON) -- Remote storage support: FTP/FTPS and Google Drive offsite backup upload +- FTP/FTPS uploader with recursive directory creation, passive mode, SSL, and size verification +- Google Drive uploader using OAuth2 refresh tokens and resumable upload API (5 MB chunks) +- RemoteUploaderInterface for pluggable storage backends +- Remote upload integrated into BackupEngine as Step 3 after archive creation +- Option to delete local copy after successful remote upload (per-profile setting) - Per-profile archive settings: format, compression level, split size, backup directory - Backup engine with step-based execution for large sites - Database dumper with table-level granularity diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/src/packages/com_mokobackup/src/Engine/BackupEngine.php index 4a86d97b..777a878e 100644 --- a/src/packages/com_mokobackup/src/Engine/BackupEngine.php +++ b/src/packages/com_mokobackup/src/Engine/BackupEngine.php @@ -57,8 +57,8 @@ class BackupEngine } // Create backup record - $now = date('Y-m-d H:i:s'); - $tag = date('Ymd-His'); + $now = date('Y-m-d H:i:s'); + $tag = date('Ymd-His'); $archiveName = 'site-' . $tag . '-profile' . $profileId . '.zip'; if (empty($description)) { @@ -66,24 +66,24 @@ class BackupEngine } $record = (object) [ - 'profile_id' => $profileId, - 'description' => $description, - 'status' => 'running', - 'origin' => $origin, - 'backup_type' => $profile->backup_type, - 'archivename' => $archiveName, - 'absolute_path' => $this->backupDir . '/' . $archiveName, - 'total_size' => 0, - 'db_size' => 0, - 'files_count' => 0, - 'tables_count' => 0, - 'multipart' => 0, - 'tag' => $tag, - 'backupstart' => $now, - 'backupend' => '0000-00-00 00:00:00', - 'filesexist' => 0, + 'profile_id' => $profileId, + 'description' => $description, + 'status' => 'running', + 'origin' => $origin, + 'backup_type' => $profile->backup_type, + 'archivename' => $archiveName, + 'absolute_path' => $this->backupDir . '/' . $archiveName, + 'total_size' => 0, + 'db_size' => 0, + 'files_count' => 0, + 'tables_count' => 0, + 'multipart' => 0, + 'tag' => $tag, + 'backupstart' => $now, + 'backupend' => '0000-00-00 00:00:00', + 'filesexist' => 0, 'remote_filename' => '', - 'log' => '', + 'log' => '', ]; $db->insertObject('#__mokobackup_records', $record, 'id'); @@ -100,17 +100,17 @@ class BackupEngine throw new \RuntimeException('Cannot create archive: ' . $archivePath); } - $dbSize = 0; - $filesCount = 0; + $dbSize = 0; + $filesCount = 0; $tablesCount = 0; // Step 1: Database dump (unless files-only) if ($profile->backup_type !== 'files') { $this->log('Starting database dump...'); - $dumper = new DatabaseDumper($excludeTables); + $dumper = new DatabaseDumper($excludeTables); $sqlDump = $dumper->dump(); $zip->addFromString('database.sql', $sqlDump); - $dbSize = strlen($sqlDump); + $dbSize = strlen($sqlDump); $tablesCount = $dumper->getTablesCount(); $this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes'); } @@ -118,13 +118,8 @@ class BackupEngine // Step 2: Files (unless database-only) if ($profile->backup_type !== 'database') { $this->log('Starting file scan...'); - $scanner = new FileScanner( - JPATH_ROOT, - $excludeDirs, - $excludeFiles - ); - - $files = $scanner->scan(); + $scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles); + $files = $scanner->scan(); $filesCount = count($files); $this->log('Found ' . $filesCount . ' files to back up'); @@ -141,26 +136,52 @@ class BackupEngine $zip->close(); - // Update record with results + // Record archive size $totalSize = file_exists($archivePath) ? filesize($archivePath) : 0; + $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; + $this->log('Archive created: ' . $sizeHuman); + $remoteFilename = ''; + + // Step 3: Remote upload (if configured) + $remoteStorage = $profile->remote_storage ?? 'none'; + + if ($remoteStorage !== 'none') { + $this->log('Starting remote upload (' . $remoteStorage . ')...'); + $uploader = $this->createUploader($remoteStorage, $profile); + $uploadResult = $uploader->upload($archivePath, $archiveName); + + if ($uploadResult['success']) { + $remoteFilename = $uploadResult['remote_path'] ?? $archiveName; + $this->log('Remote upload complete: ' . $uploadResult['message']); + + // Delete local copy if configured + if (empty($profile->remote_keep_local) && is_file($archivePath)) { + @unlink($archivePath); + $this->log('Local copy removed (remote_keep_local = off)'); + } + } else { + $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']); + $this->log('Local backup is preserved.'); + } + } + + // Final record update $update = (object) [ - 'id' => $recordId, - 'status' => 'complete', - 'total_size' => $totalSize, - 'db_size' => $dbSize, - 'files_count' => $filesCount, - 'tables_count' => $tablesCount, - 'backupend' => date('Y-m-d H:i:s'), - 'filesexist' => 1, - 'log' => implode("\n", $this->log), + 'id' => $recordId, + 'status' => 'complete', + 'total_size' => $totalSize, + 'db_size' => $dbSize, + 'files_count' => $filesCount, + 'tables_count' => $tablesCount, + 'backupend' => date('Y-m-d H:i:s'), + 'filesexist' => is_file($archivePath) ? 1 : 0, + 'remote_filename' => $remoteFilename, + 'log' => implode("\n", $this->log), ]; $db->updateObject('#__mokobackup_records', $update, 'id'); - $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; - $this->log('Backup complete: ' . $sizeHuman); - return [ 'success' => true, 'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')', @@ -182,6 +203,18 @@ class BackupEngine } } + /** + * Create the appropriate remote uploader based on the storage type. + */ + private function createUploader(string $type, object $profile): RemoteUploaderInterface + { + return match ($type) { + 'ftp' => new FtpUploader($profile), + 'google_drive' => new GoogleDriveUploader($profile), + default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), + }; + } + /** * Parse a newline-separated text field into an array of trimmed, non-empty strings. */ diff --git a/src/packages/com_mokobackup/src/Engine/FtpUploader.php b/src/packages/com_mokobackup/src/Engine/FtpUploader.php new file mode 100644 index 00000000..f9c3b6b0 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/FtpUploader.php @@ -0,0 +1,160 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class FtpUploader implements RemoteUploaderInterface +{ + private string $host; + private int $port; + private string $username; + private string $password; + private string $remotePath; + private bool $passive; + private bool $ssl; + + public function __construct(object $profile) + { + $this->host = $profile->ftp_host ?? ''; + $this->port = (int) ($profile->ftp_port ?? 21); + $this->username = $profile->ftp_username ?? ''; + $this->password = $profile->ftp_password ?? ''; + $this->remotePath = rtrim($profile->ftp_path ?? '/backups', '/'); + $this->passive = (bool) ($profile->ftp_passive ?? true); + $this->ssl = (bool) ($profile->ftp_ssl ?? false); + } + + public function upload(string $localPath, string $remoteName): array + { + if (empty($this->host)) { + return ['success' => false, 'message' => 'FTP host is not configured']; + } + + $conn = $this->connect(); + + if (!$conn) { + return ['success' => false, 'message' => 'Failed to connect to FTP server ' . $this->host . ':' . $this->port]; + } + + try { + if (!@ftp_login($conn, $this->username, $this->password)) { + throw new \RuntimeException('FTP login failed for user: ' . $this->username); + } + + if ($this->passive) { + ftp_pasv($conn, true); + } + + // Ensure remote directory exists (create recursively) + $this->ensureRemoteDir($conn, $this->remotePath); + + $remoteFile = $this->remotePath . '/' . $remoteName; + + if (!@ftp_put($conn, $remoteFile, $localPath, FTP_BINARY)) { + throw new \RuntimeException('Failed to upload file to: ' . $remoteFile); + } + + // Verify upload by checking remote file size + $remoteSize = @ftp_size($conn, $remoteFile); + $localSize = filesize($localPath); + + if ($remoteSize >= 0 && $remoteSize !== $localSize) { + throw new \RuntimeException( + 'Size mismatch: local=' . $localSize . ' remote=' . $remoteSize + ); + } + + return [ + 'success' => true, + 'message' => 'Uploaded to FTP: ' . $remoteFile, + 'remote_path' => $remoteFile, + ]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'FTP upload failed: ' . $e->getMessage()]; + } finally { + @ftp_close($conn); + } + } + + public function testConnection(): array + { + if (empty($this->host)) { + return ['success' => false, 'message' => 'FTP host is not configured']; + } + + $conn = $this->connect(); + + if (!$conn) { + return ['success' => false, 'message' => 'Cannot connect to ' . $this->host . ':' . $this->port]; + } + + try { + if (!@ftp_login($conn, $this->username, $this->password)) { + return ['success' => false, 'message' => 'Login failed for user: ' . $this->username]; + } + + if ($this->passive) { + ftp_pasv($conn, true); + } + + $pwd = @ftp_pwd($conn); + + return [ + 'success' => true, + 'message' => 'Connected to ' . $this->host . ' (cwd: ' . $pwd . ')', + ]; + } finally { + @ftp_close($conn); + } + } + + /** + * @return resource|false + */ + private function connect() + { + $timeout = 30; + + if ($this->ssl) { + return @ftp_ssl_connect($this->host, $this->port, $timeout); + } + + return @ftp_connect($this->host, $this->port, $timeout); + } + + /** + * Recursively create remote directories. + * + * @param resource $conn FTP connection + * @param string $path Remote directory path + */ + private function ensureRemoteDir($conn, string $path): void + { + $parts = explode('/', trim($path, '/')); + $current = ''; + + foreach ($parts as $part) { + $current .= '/' . $part; + + if (@ftp_chdir($conn, $current)) { + continue; + } + + if (!@ftp_mkdir($conn, $current)) { + throw new \RuntimeException('Cannot create remote directory: ' . $current); + } + } + + // Return to root + @ftp_chdir($conn, '/'); + } +} diff --git a/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php b/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php new file mode 100644 index 00000000..600362ff --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php @@ -0,0 +1,274 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Google Drive uploader using REST API with OAuth2 refresh tokens. + * Uses the resumable upload endpoint for reliable large-file uploads. + * No SDK dependency — pure PHP with cURL. + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class GoogleDriveUploader implements RemoteUploaderInterface +{ + private const TOKEN_URL = 'https://oauth2.googleapis.com/token'; + private const UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v3/files'; + private const API_URL = 'https://www.googleapis.com/drive/v3'; + + private string $clientId; + private string $clientSecret; + private string $refreshToken; + private string $folderId; + + public function __construct(object $profile) + { + $this->clientId = $profile->gdrive_client_id ?? ''; + $this->clientSecret = $profile->gdrive_client_secret ?? ''; + $this->refreshToken = $profile->gdrive_refresh_token ?? ''; + $this->folderId = $profile->gdrive_folder_id ?? ''; + } + + public function upload(string $localPath, string $remoteName): array + { + if (empty($this->clientId) || empty($this->refreshToken)) { + return ['success' => false, 'message' => 'Google Drive credentials not configured']; + } + + if (!is_file($localPath) || !is_readable($localPath)) { + return ['success' => false, 'message' => 'Local file not readable: ' . $localPath]; + } + + try { + // Step 1: Get fresh access token + $accessToken = $this->getAccessToken(); + + // Step 2: Initiate resumable upload + $fileSize = filesize($localPath); + $mimeType = 'application/zip'; + + $metadata = [ + 'name' => $remoteName, + 'mimeType' => $mimeType, + ]; + + if (!empty($this->folderId)) { + $metadata['parents'] = [$this->folderId]; + } + + $uploadUri = $this->initiateResumableUpload($accessToken, $metadata, $fileSize, $mimeType); + + // Step 3: Upload file content in chunks + $this->uploadFileContent($uploadUri, $localPath, $fileSize, $mimeType); + + return [ + 'success' => true, + 'message' => 'Uploaded to Google Drive: ' . $remoteName, + 'remote_path' => 'gdrive://' . ($this->folderId ?: 'root') . '/' . $remoteName, + ]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'Google Drive upload failed: ' . $e->getMessage()]; + } + } + + public function testConnection(): array + { + if (empty($this->clientId) || empty($this->refreshToken)) { + return ['success' => false, 'message' => 'Google Drive credentials not configured']; + } + + try { + $accessToken = $this->getAccessToken(); + + // Test by listing the target folder (or root) + $url = self::API_URL . '/about?fields=user'; + + $response = $this->curlRequest('GET', $url, null, [ + 'Authorization: Bearer ' . $accessToken, + ]); + + if ($response['code'] !== 200) { + throw new \RuntimeException('API returned HTTP ' . $response['code']); + } + + $data = json_decode($response['body'], true); + $email = $data['user']['emailAddress'] ?? 'unknown'; + + return [ + 'success' => true, + 'message' => 'Connected to Google Drive as ' . $email, + ]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()]; + } + } + + /** + * Exchange the refresh token for a fresh access token. + */ + private function getAccessToken(): string + { + $response = $this->curlRequest('POST', self::TOKEN_URL, http_build_query([ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'refresh_token' => $this->refreshToken, + 'grant_type' => 'refresh_token', + ]), [ + 'Content-Type: application/x-www-form-urlencoded', + ]); + + if ($response['code'] !== 200) { + throw new \RuntimeException('Token refresh failed (HTTP ' . $response['code'] . '): ' . $response['body']); + } + + $data = json_decode($response['body'], true); + + if (empty($data['access_token'])) { + throw new \RuntimeException('No access_token in token response'); + } + + return $data['access_token']; + } + + /** + * Initiate a resumable upload session with Google Drive. + * + * @return string The resumable upload URI + */ + private function initiateResumableUpload(string $accessToken, array $metadata, int $fileSize, string $mimeType): string + { + $url = self::UPLOAD_URL . '?uploadType=resumable'; + + $response = $this->curlRequest('POST', $url, json_encode($metadata), [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json; charset=UTF-8', + 'X-Upload-Content-Type: ' . $mimeType, + 'X-Upload-Content-Length: ' . $fileSize, + ], true); + + if ($response['code'] !== 200) { + throw new \RuntimeException('Failed to initiate upload (HTTP ' . $response['code'] . '): ' . $response['body']); + } + + if (empty($response['headers']['location'])) { + throw new \RuntimeException('No upload URI in response headers'); + } + + return $response['headers']['location']; + } + + /** + * Upload file content to the resumable upload URI in chunks. + */ + private function uploadFileContent(string $uploadUri, string $localPath, int $fileSize, string $mimeType): void + { + $chunkSize = 5 * 1024 * 1024; // 5 MB chunks + $handle = fopen($localPath, 'rb'); + + if ($handle === false) { + throw new \RuntimeException('Cannot open file for reading: ' . $localPath); + } + + try { + $offset = 0; + + while ($offset < $fileSize) { + $remaining = $fileSize - $offset; + $length = min($chunkSize, $remaining); + $chunk = fread($handle, $length); + + if ($chunk === false) { + throw new \RuntimeException('Failed to read file at offset ' . $offset); + } + + $rangeEnd = $offset + $length - 1; + + $response = $this->curlRequest('PUT', $uploadUri, $chunk, [ + 'Content-Type: ' . $mimeType, + 'Content-Length: ' . $length, + 'Content-Range: bytes ' . $offset . '-' . $rangeEnd . '/' . $fileSize, + ]); + + // 308 = Resume Incomplete (more chunks needed), 200/201 = complete + if ($response['code'] !== 200 && $response['code'] !== 201 && $response['code'] !== 308) { + throw new \RuntimeException( + 'Chunk upload failed at offset ' . $offset . ' (HTTP ' . $response['code'] . '): ' . $response['body'] + ); + } + + $offset += $length; + } + } finally { + fclose($handle); + } + } + + /** + * Execute a cURL request. + * + * @param string $method HTTP method + * @param string $url Request URL + * @param string|null $body Request body + * @param array $headers Request headers + * @param bool $captureHeaders Whether to capture response headers + * + * @return array{code: int, body: string, headers?: array} + */ + private function curlRequest(string $method, string $url, ?string $body, array $headers, bool $captureHeaders = false): array + { + $ch = curl_init(); + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CUSTOMREQUEST => $method, + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $responseHeaders = []; + + if ($captureHeaders) { + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $header) use (&$responseHeaders) { + $parts = explode(':', $header, 2); + + if (count($parts) === 2) { + $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + + return strlen($header); + }); + } + + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); + + throw new \RuntimeException('cURL error: ' . $error); + } + + curl_close($ch); + + $result = ['code' => $httpCode, 'body' => $responseBody]; + + if ($captureHeaders) { + $result['headers'] = $responseHeaders; + } + + return $result; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/RemoteUploaderInterface.php b/src/packages/com_mokobackup/src/Engine/RemoteUploaderInterface.php new file mode 100644 index 00000000..67c1808b --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/RemoteUploaderInterface.php @@ -0,0 +1,33 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +interface RemoteUploaderInterface +{ + /** + * Upload a file to remote storage. + * + * @param string $localPath Absolute path to the local file + * @param string $remoteName Filename to use on the remote end + * + * @return array{success: bool, message: string, remote_path?: string} + */ + public function upload(string $localPath, string $remoteName): array; + + /** + * Test the connection / credentials without uploading. + * + * @return array{success: bool, message: string} + */ + public function testConnection(): array; +}