a0c6332372
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
The mokobackup→mokojoombackup rename created double-nested directories (e.g. com_mokojoombackup/com_mokojoombackup/). Joomla installer could not find files at the expected paths. Flattened all packages. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
279 lines
8.1 KiB
PHP
279 lines
8.1 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoJoomBackup
|
|
* @subpackage com_mokojoombackup
|
|
* @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\MokoJoomBackup\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 (!extension_loaded('curl')) {
|
|
return ['success' => false, 'message' => 'PHP ext-curl is required for Google Drive uploads. Enable it in php.ini.'];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|