File Manager

Current Directory: /home/astoriaah/www/administrator/components/com_akeeba/BackupEngine/Postproc/Connector
Viewing File: /home/astoriaah/www/administrator/components/com_akeeba/BackupEngine/Postproc/Connector/Box.php
<?php /** * Akeeba Engine * * @package akeebaengine * @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd * @license GNU General Public License version 3, or later */ namespace Akeeba\Engine\Postproc\Connector; defined('AKEEBAENGINE') || die(); use Akeeba\Engine\Postproc\Connector\Box\Exception\APIError; use Akeeba\Engine\Postproc\Connector\Box\Exception\cURLError; use Akeeba\Engine\Postproc\Connector\Box\Exception\InvalidJSON; use Akeeba\Engine\Postproc\Connector\Box\Exception\UnexpectedHTTPStatus; use Akeeba\Engine\Postproc\ProxyAware; use Akeeba\Engine\Util\FileCloseAware; use CURLFile; use RuntimeException; /** * Box.com API connector * * @see https://developer.box.com/v2.0/reference * * WARNING! WE DO NOT SUPPORT CHUNKED UPLOADS FOR A VERY GOOD REASON. * * The Box.com API does not allow us to select the chunk size when uploading files. The chunked upload methods returns * a part size when you create a chunked upload session and you MUST use EXACTLY this part size. This tends to be rather * high, defeating the purpose of using it to work around server timeout limitations. Therefore we only implement single * part uploads. You can prevent timeouts by setting the "Part size for archive splitting" option in the archiver to * create smaller files which will upload without timing out. */ class Box { use FileCloseAware; use ProxyAware; /** * The root URL for the Box API */ public const rootUrl = 'https://api.box.com/2.0/'; /** * The root URL for the Box API */ public const uploadUrl = 'https://upload.box.com/api/2.0/'; /** * The URL of the helper script which is used to get fresh API tokens */ public const helperUrl = 'https://www.akeeba.com/oauth2/box.php'; /** * The access token for connecting to Box.com * * @var string */ private $accessToken = ''; /** * The refresh token for connecting to Box.com * * @var string */ private $refreshToken = ''; /** * Download ID to use with the helper URL * * @var string */ private $dlid = ''; /** * Default cURL options * * @var array */ private $defaultOptions = [ CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_VERBOSE => false, CURLOPT_HEADER => false, CURLINFO_HEADER_OUT => false, CURLOPT_RETURNTRANSFER => true, CURLOPT_CAINFO => AKEEBA_CACERT_PEM, ]; /** * Box constructor. * * @param string $accessToken The Box access token * @param string $refreshToken The Box refresh token * @param string $dlid The akeeba.com Download ID, used whenever you try to refresh the token */ public function __construct($accessToken, $refreshToken, $dlid) { $this->accessToken = $accessToken; $this->refreshToken = $refreshToken; $this->dlid = $dlid; } /** * Try to ping Box.com, refresh the token if it's expired and return the refresh results. * * If no refresh was required 'needs_refresh' will be false. * * If refresh was required 'needs_refresh' will be true and the rest of the keys will be as returned by Box.com. * * If the refresh failed you'll get a RuntimeException. * * @param bool $forceRefresh Set to true to forcibly refresh the tokens * * @return array * * @throws RuntimeException */ public function ping($forceRefresh = false) { // Initialization $response = [ 'needs_refresh' => false, ]; // If we're not force refreshing the tokens try to get the drive information. It's our test to see if the token // works. if (!$forceRefresh) { try { $dummy = $this->getCurrentUser(); } catch (UnexpectedHTTPStatus $e) { // If it failed we need to refresh the token $response['needs_refresh'] = true; } } // If there is no need to refresh the tokens, return if (!$response['needs_refresh'] && !$forceRefresh) { return $response; } $refreshResponse = $this->refreshToken(); return array_merge($response, $refreshResponse); } /** * Refresh the access token. * * @return array|string The result coming from Box */ public function refreshToken() { $refreshQuery = '?refresh_token=' . urlencode($this->refreshToken) . '&dlid=' . urlencode($this->dlid); $refreshResponse = $this->fetch('GET', self::helperUrl, $refreshQuery); $this->refreshToken = $refreshResponse['refresh_token'] ?? $this->refreshToken; $this->accessToken = $refreshResponse['access_token'] ?? $this->accessToken; $refreshResponse['refresh_token'] = $this->refreshToken; $refreshResponse['access_token'] = $this->accessToken; return $refreshResponse; } /** * Returns information about the current user. The most important fields are: * * space_amount: how much total space is available to you (in bytes) * * space_user: how much space you have already used (in bytes) * * max_upload_size: maximum uploaded file size * You need to issue an error when the uploaded file is larger than EITHER space_amount - space_user OR * max_upload_size because either would cause the upload to fail. In the first case the Box account is running out * of free space. In the latter case you would be trying to exceed the maximum upload size limitation (as low as * 250MB on free / personal accounts). * * @return array */ public function getCurrentUser() { return $this->fetch('GET', self::rootUrl, 'users/me'); } /** * Lists the contents of a folder. * * @param int $parentId The ID of the folder to list. 0 is the root folder. * @param int $offset Offset of the pagianted listing, 0 to start from the very beginning. * * @return array An array with keys files and folders. Each sub-array is keyed on file/folder name and the value * is the file's/folder's numeric ID. */ public function listFolder($parentId = 0, $offset = 0) { $parentId = (int) $parentId; $offset = min((int) $offset, 0); $url = "folders/$parentId/items?limit=1000"; if ($offset > 0) { $url .= '&offset=' . $offset; } $ret = [ 'files' => [], 'folders' => [], ]; $apiReturn = $this->fetch('GET', self::rootUrl, $url); if (!is_array($apiReturn)) { return $ret; } $totalCount = $apiReturn['total_count'] ?? 0; if ($totalCount === 0) { return $ret; } if (!isset($apiReturn['entries']) || empty($apiReturn['entries'])) { return $ret; } foreach ($apiReturn['entries'] as $entry) { if (!in_array($entry['type'], ['file', 'folder'])) { continue; } $target = ($entry['type'] == 'file') ? 'files' : 'folders'; $name = $entry['name']; $id = $entry['id']; $ret[$target][$name] = $id; } /** * The API paginates at $limit (default: 1000) items. If we get exactly $limit items we probably have more * pages of information to fetch. This will recurse through all the pages. */ $limit = $apiReturn['limit'] ?? 1000; $offset = $apiReturn['offset'] ?? $offset; if ($totalCount == $limit) { $offset += $limit; $moreItems = $this->listFolder($parentId, $offset); $ret = array_merge_recursive($ret, $moreItems); } return $ret; } /** * Create a folder * * @param string $name The name of the folder to create * @param int $parentId The ID of the parent folder. 0 is always the root folder * * @return int The ID of the created folder */ public function createFolder($name, $parentId = 0) { $apiReturn = []; $parentId = (int) $parentId; $url = "folders"; $postData = json_encode([ 'name' => $name, 'parent' => [ 'id' => $parentId, ], ]); $additional = []; try { $apiReturn = $this->fetch('POST', self::rootUrl, $url, $additional, $postData); } catch (UnexpectedHTTPStatus $e) { $httpResponse = $e->getCode(); if ($httpResponse == '409') { throw new APIError("already_exists", "Subfolder “{$name}” already exists under folder ID $parentId", 409, $e); } } if (!isset($apiReturn['id'])) { throw new APIError("", "Cannot create subfolder “{$name}” under folder ID $parentId"); } return $apiReturn['id']; } /** * Look for a folder relative to the $parentId folder and return its ID * * @param string $path The folder path to look for * @param int $parentId The parent folder to start searching in (0 = root) * @param bool $createMissing Should I create any missing folders? Default: true. * * @return int The folder ID * * @throws APIError When the folder cannot be found and/or we can't create a new folder by that name */ public function getFolderId($path, $parentId = 0, $createMissing = true) { $created = false; $path = trim($path, '/'); while (strpos($path, '//') !== false) { $path = str_replace('//', '/', $path); } $path = trim($path, '/'); if (empty($path)) { return $parentId; } $parts = explode('/', $path); foreach ($parts as $folderName) { $foundId = false; // Only search for folders if the current parent folder was not just created by us (saves us some useless requests) if (!$created) { // Get the parent folder's contents $contents = $this->listFolder($parentId); // Trawl the folder list for the one that matches our folder name foreach ($contents['folders'] as $key => $id) { if ($key == $folderName) { $foundId = $id; break; } } } if (($foundId === false) && !$createMissing) { throw new APIError("not_found", "Folder $path does not exist", 404); } elseif ($foundId === false) { $created = true; $foundId = $this->createFolder($folderName, $parentId); } $parentId = $foundId; } return $parentId; } /** * Delete a file stored in Box given its path * * @param string $remoteFile The path of the file to delete * * @return bool */ public function deleteFileByName($remoteFile) { $fileId = $this->findFileId($remoteFile); if ($fileId === false) { return false; } // Delete the file $this->fetch('DELETE', self::rootUrl, "files/$fileId", [ 'expect-status' => 204, ]); return true; } /** * Preflight check. Makes sure that the file can be uploaded to the selected path. * * @param string $remoteFile Path of the file in the Box.com account * @param string $localFile Local filesystem path of the file to upload */ public function preflight($remoteFile, $localFile) { $remotePath = dirname($remoteFile); $parentFolderId = empty($remotePath) ? 0 : $this->getFolderId($remotePath); $sha1 = @sha1_file($localFile); $postfields = json_encode([ 'name' => basename($remoteFile), 'parent' => [ 'id' => $parentFolderId, ], 'size' => @filesize($localFile), ]); $additional = [ 'headers' => [ // Yes, the header is named wrong. It's actually the SHA1 hash, not the MD5. 'Content-MD5' => $sha1, ], ]; if (empty($sha1)) { unset($additional['headers']['Content-MD5']); } try { $this->fetch('OPTIONS', self::rootUrl, 'files/content', $additional, $postfields); } catch (UnexpectedHTTPStatus $e) { switch ($e->getCode()) { case 409: $error = 'file_exists'; $errorDesc = 'File already exists'; break; case 403: $error = 'permissions_error'; $errorDesc = 'Permissions error. The file is too big for your account type, you do not have enough space or the permissions of the target directory do not allow uploading this file.'; break; default: $error = 'unexpected_status'; $errorDesc = 'Unexpected HTTP status ' . $e->getCode(); } throw new APIError($error, $errorDesc, $e->getCode(), $e); } } /** * Single part upload of a file * * @param string $remoteFile Path of the file in the Box.com account * @param string $localFile Local filesystem path of the file to upload * * @return string The ID of the uploaded file */ public function uploadSingleFile($remoteFile, $localFile) { clearstatcache(false, $localFile); // Find the parent folder ID if other than root $remotePath = dirname($remoteFile); $parentFolderId = empty($remotePath) ? 0 : $this->getFolderId($remotePath); $sha1 = @sha1_file($localFile); $postfields = [ "attributes" => json_encode([ 'name' => basename($remoteFile), 'parent' => [ 'id' => $parentFolderId, ], ]), "file" => "@$localFile", ]; /** * PHP 5.6 and later, using CURLFile instead of the at-path notation for file uploads. * * This is the recommended method in PHP 5.6 and the only supported method in PHP 7.0 and later. */ if (class_exists('\CURLFile')) { $postfields['file'] = new CURLFile($localFile); } $additional = [ 'headers' => [ // Yes, the header is named wrong. It's actually the SHA1 hash, not the MD5. 'Content-MD5' => $sha1, ], ]; if (empty($sha1)) { unset($additional['headers']['Content-MD5']); } $apiReturn = $this->fetch('POST', self::uploadUrl, 'files/content', $additional, $postfields); if ( is_array($apiReturn) && isset($apiReturn['entries']) && is_array($apiReturn['entries']) && isset($apiReturn['entries'][0]) && isset($apiReturn['entries'][0]['id']) ) { return $apiReturn['entries'][0]['id']; } throw new APIError('cannot_create', "Cannot create file $remoteFile on Box.com"); } public function download($remoteFile, $localFile) { $fileId = $this->findFileId($remoteFile); if ($fileId === false) { throw new RuntimeException("The file $remoteFile does not exist in your Box.com account"); } $fp = @fopen($localFile, 'w'); if ($fp === false) { throw new RuntimeException("Cannot open local file $localFile for writing"); } $this->fetch('GET', self::rootUrl, "files/$fileId/content", [ 'fp' => $fp, 'no-parse' => true, ]); return true; } /** * Execute an API call * * @param string $method The HTTP method * @param string $baseUrl The base URL. Use one of self::rootUrl or self::contentRootUrl * @param string $relativeUrl The relative URL to ping * @param array $additional Additional parameters * @param mixed $explicitPost Passed explicitly to POST requests if set, otherwise $additional is passed. * * @return array * @throws RuntimeException * */ protected function fetch($method, $baseUrl, $relativeUrl, array $additional = [], $explicitPost = null) { // Get full URL, if required $url = $relativeUrl; if (substr($relativeUrl, 0, 6) != 'https:') { $url = $baseUrl . ltrim($relativeUrl, '/'); } // Should I expect a specific header? $expectHttpStatus = []; if (isset($additional['expect-status'])) { $expectHttpStatus = $additional['expect-status']; if (!is_array($expectHttpStatus)) { $expectHttpStatus = [$expectHttpStatus]; } unset($additional['expect-status']); } // Am I told to not parse the result? $noParse = false; if (isset($additional['no-parse'])) { $noParse = $additional['no-parse']; unset ($additional['no-parse']); } // Am I told not to follow redirections? $followRedirect = true; if (isset($additional['follow-redirect'])) { $followRedirect = $additional['follow-redirect']; unset ($additional['follow-redirect']); } // Initialise and execute a cURL request $ch = curl_init($url); $this->applyProxySettingsToCurl($ch); // Get the default options array $options = $this->defaultOptions; // Do I have explicit cURL options to add? if (isset($additional['curl-options']) && is_array($additional['curl-options'])) { // We can't use array_merge since we have integer keys and array_merge reassigns them :( foreach ($additional['curl-options'] as $k => $v) { $options[$k] = $v; } } // Set up custom headers $headers = []; $hasAuthorizationHeader = false; if (isset($additional['headers'])) { $headers = $additional['headers']; unset ($additional['headers']); } // Add the authorization header, if it doesn't exist array_walk($headers, function ($header) use (&$hasAuthorizationHeader) { if (substr($header, 0, 15) == 'Authorization: ') { $hasAuthorizationHeader = true; } }); if (!$hasAuthorizationHeader) { $headers[] = 'Authorization: Bearer ' . $this->accessToken; } $options[CURLOPT_HTTPHEADER] = $headers; // Handle files $file = null; $fp = null; $fileMode = null; if (isset($additional['file'])) { $file = $additional['file']; unset ($additional['file']); } if (isset($additional['file_mode'])) { $fileMode = $additional['file_mode']; unset ($additional['file_mode']); } if (!isset($additional['fp']) && !empty($file)) { if (is_null($fileMode)) { $fileMode = ($method == 'GET') ? 'w' : 'r'; } $fp = @fopen($file, $fileMode); } elseif (isset($additional['fp'])) { $fp = $additional['fp']; unset($additional['fp']); } // Set up additional options if ($method == 'GET' && $fp) { $options[CURLOPT_RETURNTRANSFER] = false; $options[CURLOPT_HEADER] = false; $options[CURLOPT_FILE] = $fp; $options[CURLOPT_BINARYTRANSFER] = true; if (empty($expectHttpStatus)) { $expectHttpStatus = [200, 206]; } } elseif (in_array($method, ['POST', 'PUT']) && $fp) { $options[CURLOPT_POST] = true; $options[CURLOPT_INFILE] = $fp; if ($file) { clearstatcache(); $options[CURLOPT_INFILESIZE] = @filesize($file); } else { $options[CURLOPT_INFILESIZE] = strlen(stream_get_contents($fp)); } fseek($fp, 0); } elseif ($method == 'POST') { $options[CURLOPT_POST] = true; if ($explicitPost) { $options[CURLOPT_POSTFIELDS] = $explicitPost; } elseif (!empty($additional)) { $options[CURLOPT_POSTFIELDS] = $additional; } // This is required for some broken servers, e.g. SiteGround else { $options[CURLOPT_POSTFIELDS] = ''; } } else // Any other HTTP method, e.g. DELETE { $options[CURLOPT_CUSTOMREQUEST] = $method; if ($explicitPost) { $options[CURLOPT_POSTFIELDS] = $explicitPost; } elseif (!empty($additional)) { $options[CURLOPT_POSTFIELDS] = $additional; } } // Set the cURL options at once @curl_setopt_array($ch, $options); // Set the follow location flag if ($followRedirect) { @curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); } // Execute and parse the response $response = curl_exec($ch); $errNo = curl_errno($ch); $error = curl_error($ch); $lastHttpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // Close open file pointers $hadFile = false; if ($fp) { $hadFile = true; $this->conditionalFileClose($fp); } // Did we have a cURL error? if ($errNo) { throw new cURLError($errNo, $error); } // Close open file pointers if ($hadFile && !empty($expectHttpStatus) && !in_array($lastHttpCode, $expectHttpStatus)) { if ($file && ($method == 'GET')) { @unlink($file); } throw new UnexpectedHTTPStatus($lastHttpCode); } if (!empty($expectHttpStatus) && in_array($lastHttpCode, $expectHttpStatus)) { return []; } if (empty($expectHttpStatus) && ($lastHttpCode >= 400)) { throw new UnexpectedHTTPStatus($lastHttpCode); } if ($noParse) { return $response; } // Parse the response $originalResponse = $response; $response = json_decode($response, true); // Did we get invalid JSON data? if (!$response) { throw new InvalidJSON("Invalid JSON Data: $originalResponse"); } unset($originalResponse); if (!empty($response) && ($lastHttpCode > 201)) { $response = ['error' => $response]; } // Did we get an error response? if (isset($response['error']) && is_array($response['error'])) { $decodedError = $response['error']; throw new APIError($decodedError['code'], $decodedError['message'], 500); } return $response; } /** * @param $remoteFile * * @return bool|int */ private function findFileId($remoteFile) { $remoteName = basename($remoteFile); $remotePath = dirname($remoteFile); $parentFolderId = empty($remotePath) ? 0 : $this->getFolderId($remotePath); $contents = $this->listFolder($parentFolderId); $fileId = 0; // Get the file ID foreach ($contents['files'] as $name => $id) { if ($name == $remoteName) { $fileId = $id; } } // File not found if (empty($fileId)) { return false; } return $fileId; } }