feat: implement FTP and Google Drive remote uploaders

- Add RemoteUploaderInterface for pluggable storage backends
- Add FtpUploader: FTP/FTPS with passive mode, SSL, recursive dir
  creation, and post-upload size verification
- Add GoogleDriveUploader: OAuth2 refresh token auth, resumable upload
  API with 5 MB chunking for reliable large-file transfers
- Wire uploaders into BackupEngine Step 3: after ZIP creation, upload
  to configured remote, optionally delete local copy
- No SDK dependencies — pure PHP with cURL for Google Drive

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-02 14:03:46 -05:00
parent 201a281e3a
commit 4a159bab39
5 changed files with 548 additions and 44 deletions
+5 -1
View File
@@ -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
@@ -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.
*/
@@ -0,0 +1,160 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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, '/');
}
}
@@ -0,0 +1,274 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
}
}
@@ -0,0 +1,33 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
}