File Manager

Current Directory: /home/astoriaah/www/administrator/components/com_akeeba/BackupEngine/Postproc
Viewing File: /home/astoriaah/www/administrator/components/com_akeeba/BackupEngine/Postproc/Backblaze.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; defined('AKEEBAENGINE') || die(); use Akeeba\Engine\Factory; use Akeeba\Engine\Postproc\Connector\Backblaze as ConnectorBackblaze; use Akeeba\Engine\Postproc\Connector\Backblaze\Exception\Base as BackblazeBaseException; use Akeeba\Engine\Postproc\Connector\Backblaze\Exception\UnexpectedHTTPStatus; use Akeeba\Engine\Postproc\Exception\BadConfiguration; use Exception; use OutOfBoundsException; use RuntimeException; /** * Upload to Backblaze post-processing engine for Akeeba Engine */ class Backblaze extends Base { /** * The upload ID of the multipart upload in progress * * @var null|string */ protected $fileId = null; /** * The Upload URL structure returned by Backblaze, required in multipart uplaods * * @var ConnectorBackblaze\UploadURL */ protected $uploadUrl = null; /** * The part number for the multipart upload in progress * * @var null|int */ protected $partNumber = null; /** * The SHA-1 checksums of the uploaded chunks, used to finalise the multipart upload * * @var array */ protected $sha1Parts = []; /** * Used in log messages. * * @var string */ protected $engineLogName = 'BackBlaze B2'; /** * The prefix to use for volatile key storage * * @var string */ protected $volatileKeyPrefix = 'volatile.postproc.backblaze.'; /** * The ID of the bucket specified in the engine configuration * * @var string */ protected $bucketId = ''; /** * Initialise the class, setting its capabilities */ public function __construct() { $this->supportsDelete = true; $this->supportsDownloadToBrowser = true; $this->supportsDownloadToFile = true; } public function processPart($localFilepath, $remoteBaseName = null) { // Retrieve engine configuration data $akeebaConfig = Factory::getConfiguration(); // Load multipart information from temporary storage $this->fileId = $akeebaConfig->get($this->volatileKeyPrefix . 'fileId', null); $this->uploadUrl = new ConnectorBackblaze\UploadURL($akeebaConfig->get($this->volatileKeyPrefix . 'uploadUrl', [])); // Get the configuration parameters $engineConfig = $this->getEngineConfiguration(); $disableMultipart = $engineConfig['disableMultipart']; // The directory is a special case. First try getting a cached directory $directory = $akeebaConfig->get('volatile.postproc.directory', null); $processDirectory = false; // If there is no cached directory, fetch it from the engine configuration if (is_null($directory)) { $directory = $engineConfig['directory']; $processDirectory = true; } // The very first time we deal with the directory we need to process it. if ($processDirectory) { $directory = empty($directory) ? '' : $directory; $directory = str_replace('\\', '/', $directory); $directory = rtrim($directory, '/'); $directory = trim($directory); $directory = ltrim(Factory::getFilesystemTools()->TranslateWinPath($directory), '/'); $directory = Factory::getFilesystemTools()->replace_archive_name_variables($directory); // Store the parsed directory in temporary storage $akeebaConfig->set('volatile.postproc.directory', $directory); } /** * Get the file size and disable multipart uploads for files smaller than 5Mb or the configured chunk size, * whichever is bigger. */ $fileSize = @filesize($localFilepath); $partSize = max($this->getPartSizeForFile($localFilepath), 5242880); if ($fileSize <= $partSize) { $disableMultipart = true; } // Calculate relative remote filename $remoteKey = empty($remoteBaseName) ? basename($localFilepath) : $remoteBaseName; if (!empty($directory) && ($directory != '/')) { $remoteKey = $directory . '/' . $remoteKey; } // Store the absolute remote path in the class property $this->remotePath = $remoteKey; /** @var ConnectorBackblaze $connector */ $connector = $this->getConnector(); $bucketId = $this->getBucketId(); // Are we already processing a multipart upload or asked to perform a multipart upload? if (!empty($this->fileId) || !$disableMultipart) { $this->partNumber = $akeebaConfig->get($this->volatileKeyPrefix . 'partNumber', null); $this->sha1Parts = $akeebaConfig->get($this->volatileKeyPrefix . 'sha1Parts', '[]'); $this->sha1Parts = json_decode($this->sha1Parts, true); $this->sha1Parts = empty($this->sha1Parts) ? [] : $this->sha1Parts; return $this->multipartUpload($bucketId, $remoteKey, $localFilepath, $connector); } return $this->simpleUpload($bucketId, $remoteKey, $localFilepath, $connector); } public function delete($path) { /** @var ConnectorBackblaze $connector */ $connector = $this->getConnector(); $bucketId = $this->getBucketId(); $connector->deleteByFileName($bucketId, $path); } public function downloadToFile($remotePath, $localFile, $fromOffset = null, $length = null) { /** @var ConnectorBackblaze $connector */ $connector = $this->getConnector(); $engineConfig = $this->getEngineConfiguration(); $bucket = $engineConfig['bucket']; $bucket = str_replace('/', '', $bucket); $headers = []; if (!is_null($fromOffset) && $length) { $toOffset = $fromOffset + $length - 1; $headers[] = 'Range: bytes=' . $fromOffset . '-' . $toOffset; } $connector->downloadFile($bucket, $remotePath, $localFile, $headers); } public function downloadToBrowser($remotePath) { /** @var ConnectorBackblaze $connector */ $connector = $this->getConnector(); $engineConfig = $this->getEngineConfiguration(); $bucket = $engineConfig['bucket']; $bucket = str_replace('/', '', $bucket); return $connector->getSignedUrl($bucket, $remotePath, 30); } protected function makeConnector() { // Retrieve engine configuration data $config = $this->getEngineConfiguration(); // Get the configuration parameters $accountId = $config['accountId']; $applicationKey = $config['applicationKey']; $bucket = $config['bucket']; // Remove any slashes from the bucket $bucket = str_replace('/', '', $bucket); // Sanity checks if (empty($accountId)) { throw new BadConfiguration('You have not set up your ' . $this->engineLogName . ' Account ID'); } if (empty($applicationKey)) { throw new BadConfiguration('You have not set up your ' . $this->engineLogName . ' Application Key'); } if (!function_exists('curl_init')) { throw new BadConfiguration('cURL is not enabled, please enable it in order to post-process your archives'); } if (empty($bucket)) { throw new BadConfiguration('You have not set up your ' . $this->engineLogName . ' Bucket'); } // Create the API connector instance return new ConnectorBackblaze($accountId, $applicationKey); } /** * Start a multipart upload * * @param string $bucketId The bucket ID to upload to * @param string $remoteKey The remote filename * @param string $sourceFile The full path to the local source file * @param ConnectorBackblaze $connector The S3 client object instance * * @return bool True when we're done uploading, false if we have more parts * * @throws Exception When an error occurs */ private function multipartUpload($bucketId, $remoteKey, $sourceFile, ConnectorBackblaze $connector) { if (empty($this->fileId)) { Factory::getLog()->debug(sprintf( "%s -- Beginning multipart upload of %s", $this->engineLogName, $sourceFile )); // Initialise the multipart upload if necessary try { $fileInfo = $connector->startUpload($bucketId, $remoteKey); $this->fileId = $fileInfo->fileId; Factory::getLog()->debug(sprintf( "%s -- Got fileID %s", $this->engineLogName, $this->fileId )); $this->uploadUrl = $connector->getPartUploadUrl($this->fileId); $this->partNumber = 1; $this->sha1Parts = []; Factory::getLog()->debug(sprintf( "%s -- Got uploadURL %s - Upload Authorization %s", $this->engineLogName, $this->uploadUrl->uploadUrl, $this->uploadUrl->authorizationToken )); } catch (Exception $e) { Factory::getLog()->debug(sprintf( "%s -- Failed to initialize multipart upload of %s", $this->engineLogName, $sourceFile )); throw new RuntimeException('Multipart upload cannot be initialised.', 500, $e); } } else { Factory::getLog() ->debug(sprintf( "%s -- Continuing multipart upload of %s (fileId: %s –– Part number %d)", $this->engineLogName, $sourceFile, $this->fileId, $this->partNumber )); } // Upload a chunk $mustFinalize = false; try { $partSize = $this->getPartSizeForFile($sourceFile); $fileInfo = $connector->uploadPart($this->uploadUrl, $sourceFile, $this->partNumber, $partSize); $this->sha1Parts[] = $fileInfo->contentSha1; $this->partNumber++; } catch (UnexpectedHTTPStatus $e) { /** * If we get an HTTP 500 or 503 it means that the BackBlaze B2 storage pod is full. In this case B2 tells us * to request a new upload URL (which will be in another pod) and retry the upload. Rinse and repeat until * the upload completes. * * Implementation: * * - If the unexpected HTTP status is not 500 or 503 I need to rethrow the exception. In this case something * actually went wrong and we need to stop. * - I need to notify the user that no, the chunk did not upload. * - I DO NOT need to roll back the current chunk number or remove its stored SHA1 from the cache. Remember, * we failed at $connector->uploadPart which comes BEFORE we modify $this->sha1Parts and $this->partNumber * - I do need to get a new upload URL with the stored file ID. * - I need to return false, indicating we have more work to do here. */ if (!in_array($e->getCode(), [500, 503])) { throw $e; } Factory::getLog() ->debug(sprintf( "%s -- BackBlaze B2 storage pod full (BackBlaze B2 returned HTTP %u). Getting new part upload URL. The upload will resume later.", $this->engineLogName, $e->getCode() )); // Get a new upload URL $this->uploadUrl = $connector->getPartUploadUrl($this->fileId); return false; } catch (OutOfBoundsException $e) { $mustFinalize = true; } catch (Exception $e) { Factory::getLog()->debug(sprintf( "%s -- Multipart upload of %s has failed.", $this->engineLogName, $sourceFile )); // Reset the multipart markers in temporary storage $akeebaConfig = Factory::getConfiguration(); $akeebaConfig->set($this->volatileKeyPrefix . 'fileId', null); $akeebaConfig->set($this->volatileKeyPrefix . 'uploadUrl', null); $akeebaConfig->set($this->volatileKeyPrefix . 'partNumber', null); $akeebaConfig->set($this->volatileKeyPrefix . 'sha1Parts', null); throw new RuntimeException(sprintf( "Upload cannot proceed. %s returned an error.", $this->engineLogName ), 500, $e); } // When we are done uploading we have to finalize if ($mustFinalize) { $count = count($this->sha1Parts); Factory::getLog()->debug(sprintf( "%s -- Finalising multipart upload of %s (fileId: %s –– %s parts in total)", $this->engineLogName, $sourceFile, $this->fileId, $count )); $fileInfo = $connector->finishUpload($this->fileId, $this->sha1Parts); Factory::getLog()->debug(sprintf( "%s -- Finalised multipart upload of %s (fileId: %s)", $this->engineLogName, $sourceFile, $fileInfo->fileId )); $this->fileId = null; $this->uploadUrl = null; $this->partNumber = null; $this->sha1Parts = []; } // Save the internal tracking variables $akeebaConfig = Factory::getConfiguration(); $uploadURLAsArray = is_null($this->uploadUrl) ? [] : $this->uploadUrl->toArray(); $akeebaConfig->set($this->volatileKeyPrefix . 'fileId', $this->fileId); $akeebaConfig->set($this->volatileKeyPrefix . 'uploadUrl', $uploadURLAsArray); $akeebaConfig->set($this->volatileKeyPrefix . 'partNumber', $this->partNumber); $akeebaConfig->set($this->volatileKeyPrefix . 'sha1Parts', json_encode($this->sha1Parts)); // If I have an upload ID I have to do more work if (is_string($this->fileId) && !empty($this->fileId)) { return false; } // In any other case I'm done uploading the file return true; } /** * Perform a single-step upload of a file * * @param string $bucketId The bucket ID to upload to * @param string $remoteKey The remote filename * @param string $sourceFile The full path to the local source file * @param ConnectorBackblaze $connector The S3 client object instance * * @return bool True when we're done uploading, false if we have more parts * * @throws Exception When an error occurs */ private function simpleUpload($bucketId, $remoteKey, $sourceFile, ConnectorBackblaze $connector) { Factory::getLog()->debug(sprintf( "%s -- Single part upload of %s", $this->engineLogName, basename($sourceFile) )); $tries = 0; while (true) { $tries++; try { $connector->uploadSingleFile($bucketId, $remoteKey, $sourceFile); } catch (UnexpectedHTTPStatus $e) { /** * If we get an HTTP 500 or 503 it means that the BackBlaze B2 storage pod is full. In this case B2 tells us * to request a new upload URL (which will be in another pod) and retry the upload. Rinse and repeat until * the upload completes. * * Implementation: * * - If this is the third try we give up (rethrow the exception). Something is actually wrong with B2. * - If the unexpected HTTP status is not 500 or 503 I need to rethrow the exception. In this case * something actually went wrong and we need to stop. * - I need to notify the user that no, the chunk did not upload. * - I DO NOT need to roll back the current chunk number or remove its stored SHA1 from the cache. Remember, * we failed at $connector->uploadPart which comes BEFORE we modify $this->sha1Parts and $this->partNumber * - I do need to get a new upload URL with the stored file ID. * - I need to return false, indicating we have more work to do here. */ if (($tries >= 3) || !in_array($e->getCode(), [500, 503])) { throw $e; } Factory::getLog() ->debug(sprintf( "%s -- BackBlaze B2 storage pod full (BackBlaze B2 returned HTTP %u). Retrying upload (attempt %d of a maximum of 2).", $this->engineLogName, $e->getCode(), $tries )); continue; } break; } return true; } /** * Get the configuration information for this post-processing engine * * @return array */ private function getEngineConfiguration() { $akeebaConfig = Factory::getConfiguration(); return [ 'accountId' => $akeebaConfig->get('engine.postproc.backblaze.accountId', ''), 'applicationKey' => $akeebaConfig->get('engine.postproc.backblaze.applicationKey', ''), 'disableMultipart' => $akeebaConfig->get('engine.postproc.backblaze.disableMultipart', 0), 'bucket' => $akeebaConfig->get('engine.postproc.backblaze.bucket', null), 'directory' => $akeebaConfig->get('engine.postproc.backblaze.directory', null), 'chunkInMB' => $akeebaConfig->get('engine.postproc.backblaze.chunk_upload_size', null), ]; } /** * Get the bucket ID, fetching it from BackBlaze if it's not already populated * * @return string The bucket ID * * @throws BackblazeBaseException When fetching the bucket ID is impossible * @throws Exception When we cannot get a connector object */ private function getBucketId() { if (!empty($this->bucketId)) { return $this->bucketId; } $akeebaConfig = Factory::getConfiguration(); $engineConfig = $this->getEngineConfiguration(); $bucket = $engineConfig['bucket']; $bucket = str_replace('/', '', $bucket); $connector = $this->getConnector(); $this->bucketId = $connector->getBucketId($bucket); $akeebaConfig->set($this->volatileKeyPrefix . 'bucketId', $this->bucketId); return $this->bucketId; } /** * Get the applicable part size for a given file. The part size cannot be smaller than the absolute minimum part * size reported by Backblaze (typically 5MB). It also cannot be smaller than the file size divided by 10,000 as * Backblaze will only allow us to upload up to 10,000 parts. This algorithm will try to use the user selected * part size unless it is smaller than these hard requirements. * * Finally note that the part size is cached in volatile storage so that subsequent queries about it will not result * in a performance penalty. * * @param string $sourceFile The local file we want to figure out the part size for * * @return int * * @throws Exception If getting the connector object is not possible */ private function getPartSizeForFile($sourceFile) { $akeebaConfig = Factory::getConfiguration(); $savedSourceFile = $akeebaConfig->get($this->volatileKeyPrefix . 'partSizeFile', null); $savedPartSize = $akeebaConfig->get($this->volatileKeyPrefix . 'partSizeValue', null); if ($savedSourceFile == $sourceFile) { return $savedPartSize; } // Get the part size. Must be <= 100 MB $engineConfig = $this->getEngineConfiguration(); $connector = $this->getConnector(); $minPartSize = $connector->getAccountInformation()->absoluteMinimumPartSize; $partSize = min($engineConfig['chunkInMB'], 100); $partSize = $partSize * 1024 * 1024; $partSize = max($minPartSize, $partSize); clearstatcache(false, $sourceFile); $fileSize = @filesize($sourceFile); /** * Backblaze supports up to 10000 parts. We have to try increasing the part size until we're sure our file * will upload in a number of parts that's less than that. */ while ($fileSize / $partSize > 10000) { // Increase by 5M in each step $partSize += 5242880; } $akeebaConfig->set($this->volatileKeyPrefix . 'partSizeFile', $sourceFile); $akeebaConfig->set($this->volatileKeyPrefix . 'partSizeValue', $partSize); return $partSize; } }