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