Files
MokoSuiteBackup/source/packages/com_mokojoombackup/src/Engine/GoogleDriveUploader.php
T
Jonathan Miller 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
fix: flatten nested package directories from rename
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>
2026-06-06 15:11:07 -05:00

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;
}
}