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:
+5
-1
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user