Alex Trofimov 6 лет назад
Родитель
Сommit
08ae5f8362
36 измененных файлов с 4752 добавлено и 22 удалено
  1. 8 1
      inc/classes/BxDolStorage.php
  2. 33 14
      inc/classes/BxDolStorageS3.php
  3. 159 0
      inc/classes/BxDolStorageS3v4.php
  4. 5 0
      inc/utils.inc.php
  5. 5 2
      install/sql/system.sql
  6. 4 0
      modules/boonex/english/data/langs/system/en.xml
  7. 4 0
      modules/boonex/russian/data/langs/system/ru.xml
  8. 207 0
      plugins/akeeba-s3/README.md
  9. 31 0
      plugins/akeeba-s3/src/Acl.php
  10. 337 0
      plugins/akeeba-s3/src/Configuration.php
  11. 935 0
      plugins/akeeba-s3/src/Connector.php
  12. 17 0
      plugins/akeeba-s3/src/Exception/CannotDeleteFile.php
  13. 17 0
      plugins/akeeba-s3/src/Exception/CannotGetBucket.php
  14. 17 0
      plugins/akeeba-s3/src/Exception/CannotGetFile.php
  15. 17 0
      plugins/akeeba-s3/src/Exception/CannotListBuckets.php
  16. 26 0
      plugins/akeeba-s3/src/Exception/CannotOpenFileForRead.php
  17. 26 0
      plugins/akeeba-s3/src/Exception/CannotOpenFileForWrite.php
  18. 17 0
      plugins/akeeba-s3/src/Exception/CannotPutFile.php
  19. 21 0
      plugins/akeeba-s3/src/Exception/ConfigurationError.php
  20. 32 0
      plugins/akeeba-s3/src/Exception/InvalidAccessKey.php
  21. 32 0
      plugins/akeeba-s3/src/Exception/InvalidBody.php
  22. 32 0
      plugins/akeeba-s3/src/Exception/InvalidEndpoint.php
  23. 29 0
      plugins/akeeba-s3/src/Exception/InvalidFilePointer.php
  24. 32 0
      plugins/akeeba-s3/src/Exception/InvalidRegion.php
  25. 32 0
      plugins/akeeba-s3/src/Exception/InvalidSecretKey.php
  26. 32 0
      plugins/akeeba-s3/src/Exception/InvalidSignatureMethod.php
  27. 20 0
      plugins/akeeba-s3/src/Exception/PropertyNotFound.php
  28. 717 0
      plugins/akeeba-s3/src/Input.php
  29. 755 0
      plugins/akeeba-s3/src/Request.php
  30. 337 0
      plugins/akeeba-s3/src/Response.php
  31. 139 0
      plugins/akeeba-s3/src/Response/Error.php
  32. 80 0
      plugins/akeeba-s3/src/Signature.php
  33. 208 0
      plugins/akeeba-s3/src/Signature/V2.php
  34. 379 0
      plugins/akeeba-s3/src/Signature/V4.php
  35. 3 2
      plugins/amazon-s3/S3.php
  36. 7 3
      samples/storage.php

+ 8 - 1
inc/classes/BxDolStorage.php

@@ -44,12 +44,13 @@ define('BX_DOL_STORAGE_ERR_SITE_QUOTA_EXCEEDED', 1007); ///< site quota exceeded
 define('BX_DOL_STORAGE_ERR_ENGINE_ADD', 1008); ///< some other error during file adding occured, related to particular storage engine
 
 define('BX_DOL_STORAGE_ERR_FILE_NOT_FOUND', 2001); ///< file not found
-define('BX_DOL_STORAGE_ERR_UNLINK', 2002); ///< file deletiotion failed
+define('BX_DOL_STORAGE_ERR_UNLINK', 2002); ///< file deletion failed
 
 define('BX_DOL_STORAGE_ERR_DB', 5001); ///< database error
 define('BX_DOL_STORAGE_ERR_FILESYSTEM_PERM', 5002); ///< filesystem permissions error
 define('BX_DOL_STORAGE_ERR_PERMISSION_DENIED', 5003); ///< permission denied
 define('BX_DOL_STORAGE_ERR_ENGINE_GET', 5004); ///< some other error during file getting occured, related to particular storage engine
+define('BX_DOL_STORAGE_ERR_NOT_IMPLEMENTED', 5005); ///< this feature isn't implemented in storage engine
 
 define('BX_DOL_STORAGE_DEFAULT_MIME_TYPE', 'octet/stream'); ///< default mime type if it is not find by extension
 
@@ -317,6 +318,11 @@ abstract class BxDolStorage extends BxDolFactory implements iBxDolFactoryObject
     	return $this->_aObject['object'];
     }
 
+    public function getObjectData()
+    {
+    	return $this->_aObject;
+    }
+
     /**
      * Get error code from the last occured error
      * @return error code
@@ -351,6 +357,7 @@ abstract class BxDolStorage extends BxDolFactory implements iBxDolFactoryObject
             5002 => '_sys_storage_err_filesystem_perm',
             5003 => '_sys_storage_err_permission_denied',
             5004 => '_sys_storage_err_engine_get',
+            5005 => '_sys_storage_err_not_implemented',
         );
         return _t($a[$this->_iErrorCode]);
     }

+ 33 - 14
inc/classes/BxDolStorageS3.php

@@ -10,8 +10,6 @@
 if (!defined('CURL_SSLVERSION_TLSv1'))
     define('CURL_SSLVERSION_TLSv1', 1);
 
-require_once(BX_DIRECTORY_PATH_PLUGINS . 'amazon-s3/S3.php');
-
 /**
  * File storage in Amazon S3.
  * @see BxDolStorage
@@ -21,6 +19,7 @@ class BxDolStorageS3 extends BxDolStorage
     protected $_s3;
     protected $_sBucket;
     protected $_sDomain;
+    protected $_sEndpoint;
     protected $_bSSL;
     protected $_bReducedRedundancy;
 
@@ -31,14 +30,25 @@ class BxDolStorageS3 extends BxDolStorage
     {
         parent::__construct($aObject);
 
-        $sAccessKey = getParam('sys_storage_s3_access_key');
-        $sSecretKey = getParam('sys_storage_s3_secret_key');
-        $this->_s3 = new S3($sAccessKey, $sSecretKey);
+        $this->_bSSL = (isset($this->_aParams['ssl']) && $this->_aParams['ssl']) || (!isset($this->_aParams['ssl']) && strncmp(BX_DOL_URL_ROOT, 'https://', 8) === 0) ? true : false;
+
+        $this->_sEndpoint = getParam('sys_storage_s3_endpoint');
         $this->_sBucket = getParam('sys_storage_s3_bucket');
         $this->_sDomain = getParam('sys_storage_s3_domain');
-
-        $this->_bSSL = (isset($this->_aParams['ssl']) && $this->_aParams['ssl']) || (!isset($this->_aParams['ssl']) && strncmp(BX_DOL_URL_ROOT, 'https://', 8) === 0) ? true : false;
         $this->_bReducedRedundancy = isset($this->_aParams['reduced_redundancy']) && $this->_aParams['reduced_redundancy'] ? true : false;
+
+        $this->init($aObject);
+    }
+
+    protected function init ($aObject)
+    {
+        require_once(BX_DIRECTORY_PATH_PLUGINS . 'amazon-s3/S3.php');
+        $this->_s3 = new S3(
+            getParam('sys_storage_s3_access_key'), 
+            getParam('sys_storage_s3_secret_key'), 
+            $this->_bSSL, 
+            $this->_sEndpoint ? $this->_sEndpoint : 's3.amazonaws.com'
+        );
     }
 
     /**
@@ -163,14 +173,11 @@ class BxDolStorageS3 extends BxDolStorage
 
     // ----------------
 
-    protected function addFileToEngine($sTmpFile, $sLocalId, $sName, $isPrivate, $iProfileId)
+    protected function generateHeaders($sFileName, $isPrivate, $sMimeType = '')
     {
-        $sMimeType = $this->getMimeTypeByFileName($sName);
-        $sExt = $this->getFileExt($sName);
-        $sPath = $this->genPath($sLocalId, $this->_aObject['levels']);
-        $sRemoteNamePath = $sPath . $sLocalId . ($sExt ? '.' . $sExt : '');
+        if (!$sMimeType)
+            $sMimeType = $this->getMimeTypeByFileName($sFileName);
 
-        $aMetaHeaders = array();
         $aRequestHeaders = array (
             "Content-Type"  => $sMimeType,
         );
@@ -179,6 +186,18 @@ class BxDolStorageS3 extends BxDolStorage
                 "Cache-Control" => "max-age=" . ($isPrivate && $this->_iCacheControl > $this->_aObject['token_life'] ? $this->_aObject['token_life'] : $this->_iCacheControl),
             ));
         }
+    
+        return $aRequestHeaders;
+    }
+
+    protected function addFileToEngine($sTmpFile, $sLocalId, $sName, $isPrivate, $iProfileId)
+    {
+        $sMimeType = $this->getMimeTypeByFileName($sName);
+        $sExt = $this->getFileExt($sName);
+        $sPath = $this->genPath($sLocalId, $this->_aObject['levels']);
+        $sRemoteNamePath = $sPath . $sLocalId . ($sExt ? '.' . $sExt : '');
+        $aRequestHeaders = $this->generateHeaders($sName, $isPrivate);
+        $aMetaHeaders = array();
 
         $sStorageClass = $this->_bReducedRedundancy ? S3::STORAGE_CLASS_RRS : S3::STORAGE_CLASS_STANDARD;
         $sACL = $isPrivate ? S3::ACL_AUTHENTICATED_READ : S3::ACL_PUBLIC_READ;
@@ -216,7 +235,7 @@ class BxDolStorageS3 extends BxDolStorage
     protected function getObjectBaseUrl ($isPrivate = false)
     {
         $sProto = $this->_bSSL ? 'https://' : 'http://';
-        return $sProto . $this->_sBucket . '.s3.amazonaws.com/' . $this->getObjectBaseDir($isPrivate);
+        return $sProto . $this->_sBucket . '.' . ($this->_sEndpoint ? $this->_sEndpoint : 's3.amazonaws.com'). '/' . $this->getObjectBaseDir($isPrivate);
     }
 }
 

+ 159 - 0
inc/classes/BxDolStorageS3v4.php

@@ -0,0 +1,159 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+if (!defined('CURL_SSLVERSION_TLSv1'))
+    define('CURL_SSLVERSION_TLSv1', 1);
+
+if (!defined('BX_DOL_STORAGE_S3V4_MULTIPART_UPLOAD'))
+    define('BX_DOL_STORAGE_S3V4_MULTIPART_UPLOAD', 5*1024*1024);
+
+define('AKEEBAENGINE', 1);
+
+/**
+ * File storage in Amazon S3 with Signature v4 support.
+ * @see BxDolStorage
+ */
+class BxDolStorageS3v4 extends BxDolStorageS3
+{
+    protected function init ($aObject)
+    {
+        $oConfiguration = new Akeeba\Engine\Postproc\Connector\S3v4\Configuration(
+            getParam('sys_storage_s3_access_key'),
+            getParam('sys_storage_s3_secret_key'),
+            getParam('sys_storage_s3_sig_ver'),
+            getParam('sys_storage_s3_region')
+        );
+        if ($this->_sEndpoint)
+            $oConfiguration->setEndpoint($this->_sEndpoint);
+
+        $this->_s3 = new Akeeba\Engine\Postproc\Connector\S3v4\Connector($oConfiguration);
+    }
+
+    /**
+     * Get file url.
+     * @param $iFileId file
+     * @return file url
+     */
+    public function getFileUrlById($iFileId)
+    {
+        $sRet = parent::getFileUrlById($iFileId);
+        if (!$sRet)
+            return false;
+    
+        if ('s3.wasabisys.com' === $this->_sEndpoint)
+            $sRet = str_replace('//s3.', '//' . $this->_sBucket . '.s3.', $sRet);
+
+        return $sRet;
+    }
+
+    public function setFilePrivate($iFileId, $isPrivate = true)
+    {
+        // since in S3v4 lib this feature not implemented then we need to get file and upload it back with new ACL
+
+        $aFile = $this->_oDb->getFileById($iFileId);
+        if (!$aFile)
+            return false;
+
+        $sFileLocation = $this->getObjectBaseDir($aFile['private']) . $aFile['path'];
+        
+        $sTmpFile = tempnam(BX_DIRECTORY_PATH_TMP, $this->_aObject['object']);
+        if (!$sTmpFile) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_FILESYSTEM_PERM);
+            return false;
+        }
+
+        try {
+            $this->_s3->getObject($this->_sBucket, $sFileLocation, $sTmpFile);
+        } catch (Exception $e) {
+            @unlink($sTmpFile);
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_UNLINK);
+            return false;
+        }
+
+        $sACL = $isPrivate ? Akeeba\Engine\Postproc\Connector\S3v4\Acl::ACL_AUTHENTICATED_READ : Akeeba\Engine\Postproc\Connector\S3v4\Acl::ACL_PUBLIC_READ;
+        $aRequestHeaders = $this->generateHeaders('', $isPrivate, $aFile['mime_type']);
+        if (!$this->_upload($sTmpFile, $sFileLocation, $sACL, $aRequestHeaders))
+            return false;
+
+        return BxDolStorage::setFilePrivate($iFileId, $isPrivate);
+    }
+    
+    // ----------------
+
+    protected function addFileToEngine($sTmpFile, $sLocalId, $sName, $isPrivate, $iProfileId)
+    {
+        $sExt = $this->getFileExt($sName);
+        $sPath = $this->genPath($sLocalId, $this->_aObject['levels']);
+        $sRemoteNamePath = $sPath . $sLocalId . ($sExt ? '.' . $sExt : '');
+        $aRequestHeaders = $this->generateHeaders($sName, $isPrivate);
+        $sACL = $isPrivate ? Akeeba\Engine\Postproc\Connector\S3v4\Acl::ACL_AUTHENTICATED_READ : Akeeba\Engine\Postproc\Connector\S3v4\Acl::ACL_PUBLIC_READ;
+
+        return $this->_upload($sTmpFile, $this->getObjectBaseDir($isPrivate) . $sRemoteNamePath, $sACL, $aRequestHeaders);
+    }
+
+    protected function _upload($sInputFile, $sUri, $sACL, $aRequestHeaders)
+    {
+        try {
+            $oInputFile = Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromFile($sInputFile);
+            if (filesize($sInputFile) < BX_DOL_STORAGE_S3V4_MULTIPART_UPLOAD) {
+                $this->_s3->putObject($oInputFile, $this->_sBucket, $sUri, $sACL, $aRequestHeaders);
+            }
+            else {
+                $sUploadSessionId = $this->_s3->startMultipart($oInputFile, $this->_sBucket, $sUri, $sACL, $aRequestHeaders);
+
+                $sETags = array();
+                $sETag = null;
+                $iPartNumber = 0;
+
+                do
+                {
+                    // IMPORTANT: You MUST create the input afresh before each uploadMultipart call
+                    $oInput = Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromFile($sInputFile);
+                    $oInput->setUploadID($sUploadSessionId);
+                    $oInput->setPartNumber(++$iPartNumber);
+                    
+                    $sETag = $this->_s3->uploadMultipart($oInput, $this->_sBucket, $sUri);
+
+                    if (!is_null($sETag))
+                        $sETags[] = $sETag;
+                }
+                while (!is_null($sETag));
+
+                // IMPORTANT: You MUST create the input afresh before finalising the multipart upload
+                $oInput = Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromFile($sInputFile);
+                $oInput->setUploadID($sUploadSessionId);
+                $oInput->setEtags($sETags);
+
+                $this->_s3->finalizeMultipart($oInput, $this->_sBucket, $sUri);
+            }
+
+        } catch (Exception $e) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_ENGINE_ADD);
+            return false;
+        }
+
+        return true;
+    }
+
+    protected function deleteFileFromEngine($sFilePath, $isPrivate)
+    {
+        $sFileLocation = $this->getObjectBaseDir($isPrivate) . $sFilePath;
+
+        try {
+            $this->_s3->deleteObject($this->_sBucket, $sFileLocation);
+        } catch (Exception $e) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_UNLINK);
+            return false;
+        }
+
+        return true;
+    }
+}
+
+/** @} */

+ 5 - 0
inc/utils.inc.php

@@ -779,6 +779,11 @@ function bx_autoload($sClassName)
 {
     if (0 === strncmp($sClassName, 'BxDol', 5) || 0 === strncmp($sClassName, 'BxBase', 6) || 0 === strncmp($sClassName, 'BxTempl', 7))
         bx_import($sClassName);
+    else {
+        if (0 === strpos($sClassName, 'Akeeba\Engine\Postproc\Connector\S3v4')) {
+            require_once(BX_DIRECTORY_PATH_PLUGINS . 'akeeba-s3/src' . str_replace('\\', '/', substr($sClassName, 37)) . '.php');
+        }
+    }
 }
 
 /**

+ 5 - 2
install/sql/system.sql

@@ -504,11 +504,14 @@ INSERT INTO `sys_options_categories`(`type_id`, `name`, `caption`, `hidden`, `or
 SET @iCategoryId = LAST_INSERT_ID();
 
 INSERT INTO `sys_options`(`category_id`, `name`, `caption`, `value`, `type`, `extra`, `check`, `check_error`, `order`) VALUES
-(@iCategoryId, 'sys_storage_default', '_adm_stg_cpt_option_sys_storage_default', 'Local', 'select', 'Local,S3', '', '', 1),
+(@iCategoryId, 'sys_storage_default', '_adm_stg_cpt_option_sys_storage_default', 'Local', 'select', 'Local,S3,S3v4', '', '', 1),
 (@iCategoryId, 'sys_storage_s3_access_key', '_adm_stg_cpt_option_sys_storage_s3_access_key', '', 'digit', '', '', '', 2),
 (@iCategoryId, 'sys_storage_s3_secret_key', '_adm_stg_cpt_option_sys_storage_s3_secret_key', '', 'digit', '', '', '', 3),
 (@iCategoryId, 'sys_storage_s3_bucket', '_adm_stg_cpt_option_sys_storage_s3_bucket', '', 'digit', '', '', '', 4),
-(@iCategoryId, 'sys_storage_s3_domain', '_adm_stg_cpt_option_sys_storage_s3_domain', '', 'digit', '', '', '', 5);
+(@iCategoryId, 'sys_storage_s3_domain', '_adm_stg_cpt_option_sys_storage_s3_domain', '', 'digit', '', '', '', 5),
+(@iCategoryId, 'sys_storage_s3_endpoint', '_adm_stg_cpt_option_sys_storage_s3_endpoint', '', 'digit', '', '', '', 6),
+(@iCategoryId, 'sys_storage_s3_sig_ver', '_adm_stg_cpt_option_sys_storage_s3_sig_ver', 'v2', 'select', 'v2,v4', '', '', 7),
+(@iCategoryId, 'sys_storage_s3_region', '_adm_stg_cpt_option_sys_storage_s3_region', '', 'digit', '', '', '', 8);
 
 --
 -- CATEGORY: Account

+ 4 - 0
modules/boonex/english/data/langs/system/en.xml

@@ -1482,6 +1482,10 @@
 	<string name="_adm_stg_cpt_option_sys_storage_s3_bucket"><![CDATA[AWS bucket]]></string>
 	<string name="_adm_stg_cpt_option_sys_storage_s3_domain"><![CDATA[AWS custom domain (require custom DNS setup)]]></string>
 	<string name="_adm_stg_cpt_option_sys_storage_s3_secret_key"><![CDATA[AWS secret key]]></string>
+	<string name="_adm_stg_cpt_option_sys_storage_s3_endpoint"><![CDATA[Endpoint (specify if other than Amazon, such as storage.googleapis.com, s3.wasabisys.com)]]></string>
+	<string name="_adm_stg_cpt_option_sys_storage_s3_sig_ver"><![CDATA[Signature version (only S3v4 storage supports v4)]]></string>
+	<string name="_adm_stg_cpt_option_sys_storage_s3_region"><![CDATA[Region (for signature v4 only)]]></string>
+
 	<string name="_adm_stg_cpt_option_sys_template_cache_image_enable"><![CDATA[Enable cache for images]]></string>
 	<string name="_adm_stg_cpt_option_sys_template_cache_image_max_size"><![CDATA[Cached image max size (in Kb)]]></string>
         <string name="_adm_stg_cpt_option_sys_template_cache_image_max_size_err"><![CDATA[Use non-empty value less or equal to 10kb. Big values make no sense.]]></string>

+ 4 - 0
modules/boonex/russian/data/langs/system/ru.xml

@@ -1465,6 +1465,10 @@
 	<string name="_adm_stg_cpt_option_sys_storage_s3_bucket"><![CDATA[AWS - сегмент (bucket)]]></string>
 	<string name="_adm_stg_cpt_option_sys_storage_s3_domain"><![CDATA[AWS - дополнительный домен (требует дополнительной настройки DNS)]]></string>
 	<string name="_adm_stg_cpt_option_sys_storage_s3_secret_key"><![CDATA[AWS - секретный ключ]]></string>
+	<string name="_adm_stg_cpt_option_sys_storage_s3_endpoint"><![CDATA[Endpoint (например storage.googleapis.com или s3.wasabisys.com, для Amazon оставивить пустым)]]></string>
+	<string name="_adm_stg_cpt_option_sys_storage_s3_sig_ver"><![CDATA[Версия подписи (только S3v4 движок хранения поддерживает v4)]]></string>
+	<string name="_adm_stg_cpt_option_sys_storage_s3_region"><![CDATA[Регион (только для подписи v4)]]></string>
+
 	<string name="_adm_stg_cpt_option_sys_template_cache_image_enable"><![CDATA[Задействовать кэш для изображений]]></string>
 	<string name="_adm_stg_cpt_option_sys_template_cache_image_max_size"><![CDATA[Максимальный размер кэшируемого изображения (в Кб)]]></string>
         <string name="_adm_stg_cpt_option_sys_template_cache_image_max_size_err"><![CDATA[Используйте не пустое значение не больше 10Кб. Большие заначения не имеют смысла.]]></string>

+ 207 - 0
plugins/akeeba-s3/README.md

@@ -0,0 +1,207 @@
+# Akeeba Amazon S3 Connector
+
+A lightweight Amazon S3 connector implementation for PHP 5.3 or later
+
+After having a lot of impossible to debug problems with Amazon's Guzzle-based AWS SDK we decided to roll our own
+connector for Amazon S3. This is by no means a complete implementation, just a small subset of S3's features which are
+required by our software. The design goals are simplicity and low memory footprint.
+
+This code is loosely based on S3.php written by Donovan Schonknecht and available at
+http://undesigned.org.za/2007/10/22/amazon-s3-php-class under a BSD-like license. This repository no longer reflects
+the original author's work and should not be confused with it.
+
+This software is distributed under the GNU General Public License version 3 or, at your option, any
+later version published by the Free Software Foundation (FSF). In short, it's "GPLv3+".
+
+## Using the connector
+
+### Get a connector object
+
+```php
+$configuration = new Configuration(
+	'YourAmazonAccessKey',
+	'YourAmazonSecretKey'
+);
+
+$connector = new Connector($configuration);
+```
+
+If you are running inside an Amazon EC2 instance you can fetch temporary credentials from the instance's metadata
+server using the IAM Role attached to the EC2 instance. In this case you need to do this (169.254.169.254 is a fixed
+IP hosting the instance's metadata cache service):
+
+```php
+$role = file_get_contents('http://169.254.169.254/latest/meta-data/iam/security-credentials/');
+$jsonCredentials = file_get_contents('http://169.254.169.254/latest/meta-data/iam/security-credentials/' . $role);
+$credentials = json_decode($jsonCredentials, true);
+$configuration = new Configuration(
+	$credentials['AccessKeyId'],
+	$credentials['SecretAccessKey'],
+	'v4',
+	$yourRegion
+);
+$configuration->setToken($credentials['Token']);
+
+$connector = new Connector($configuration);
+```
+
+where `$yourRegion` is the AWS region of your bucket, e.g. `us-east-1`. Please note that we are passing the security
+token (`$credentials['Token']`) to the Configuration object. This is REQUIRED. The temporary credentials returned by
+the metadata service won't work without it.
+
+Also worth noting is that the temporary credentials don't last forever. Check the `$credentials['Expiration']` to see
+when they are about to expire. Amazon recommends that you retry fetching new credentials from the metadata service
+10 minutes before your cached credentials are set to expire. The metadata service is guaranteed to provision fresh
+temporary credentials by that time. 
+
+### Listing buckets
+
+```php
+$listing = $connector->listBuckets(true);
+```
+
+Returns an array like this:
+
+```
+array(2) {
+  'owner' =>
+  array(2) {
+    'id' =>
+    string(64) "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+    'name' =>
+    string(8) "someUserName"
+  }
+  'buckets' =>
+  array(3) {
+    [0] =>
+    array(2) {
+      'name' =>
+      string(10) "mybucket"
+      'time' =>
+      int(1267730711)
+    }
+    [1] =>
+    array(2) {
+      'name' =>
+      string(10) "anotherbucket"
+      'time' =>
+      int(1269516249)
+    }
+    [2] =>
+    array(2) {
+      'name' =>
+      string(11) "differentbucket"
+      'time' =>
+      int(1354458048)
+    }
+  }
+}
+```
+
+### Listing bucket contents
+
+```php
+$listing = $connector->getBucket('mybucket', 'path/to/list/');
+```
+
+If you want to list "subdirectories" you need to do
+ 
+```php
+$listing = $connector->getBucket('mybucket', 'path/to/list/', null, null, '/', true);
+```
+
+The last parameter (common prefixes) controls the listing of "subdirectories"
+
+### Uploading (small) files
+
+From a file:
+
+```php
+$input = Input::createFromFile($sourceFile);   
+$connector->putObject($input, 'mybucket', 'path/to/myfile.txt');
+```
+
+From a string:
+
+```php
+$input = Input::createFromData($sourceString);   
+$connector->putObject($input, 'mybucket', 'path/to/myfile.txt');
+```
+
+From a stream resource:
+
+```php
+$input = Input::createFromResource($streamHandle, false);   
+$connector->putObject($input, 'mybucket', 'path/to/myfile.txt');
+```
+
+In all cases the entirety of the file has to be loaded in memory.
+
+### Uploading large file with multipart (chunked) uploads
+
+Files are uploaded in 5Mb chunks.
+
+```php
+$input = Input::createFromFile($sourceFile);
+$uploadId = $connector->startMultipart($input, 'mybucket', 'mypath/movie.mov');
+
+$eTags = array();
+$eTag = null;
+$partNumber = 0;
+
+do
+{
+	// IMPORTANT: You MUST create the input afresh before each uploadMultipart call
+	$input = Input::createFromFile($sourceFile);
+	$input->setUploadID($uploadId);
+	$input->setPartNumber(++$partNumber);
+	
+	$eTag = $connector->uploadMultipart($input, 'mybucket', 'mypath/movie.mov');
+
+	if (!is_null($eTag))
+	{
+		$eTags[] = $eTag;
+	}
+}
+while (!is_null($eTag));
+
+// IMPORTANT: You MUST create the input afresh before finalising the multipart upload
+$input = Input::createFromFile($sourceFile);
+$input->setUploadID($uploadId);
+$input->setEtags($eTags);
+
+$connector->finalizeMultipart($input, 'mybucket', 'mypath/movie.mov');
+```
+
+As long as you keep track of the UploadId, PartNumber and ETags you can have each uploadMultipart call in a separate
+page load to prevent timeouts.
+
+### Get presigned URLs
+
+Allows browsers to download files directly without exposing your credentials and without going through your server:
+
+```php
+$preSignedURL = $connector->getAuthenticatedURL('mybucket', 'path/to/file.jpg', 60);
+```
+
+The last parameter controls how many seconds into the future this URL will be valid.
+
+### Download
+
+To a file with absolute path `$targetFile`
+
+```php
+$connector->getObject('mybucket', 'path/to/file.jpg', $targetFile);
+```
+
+To a string
+
+```php
+$content = $connector->getObject('mybucket', 'path/to/file.jpg', false);
+```
+
+### Delete an object
+
+```php
+$connector->deleteObject('mybucket', 'path/to/file.jpg');
+```

+ 31 - 0
plugins/akeeba-s3/src/Acl.php

@@ -0,0 +1,31 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+/**
+ * Shortcuts to often used access control privileges
+ */
+class Acl
+{
+	const ACL_PRIVATE = 'private';
+
+	const ACL_PUBLIC_READ = 'public-read';
+
+	const ACL_PUBLIC_READ_WRITE = 'public-read-write';
+
+	const ACL_AUTHENTICATED_READ = 'authenticated-read';
+
+	const ACL_BUCKET_OWNER_READ = 'bucket-owner-read';
+
+	const ACL_BUCKET_OWNER_FULL_CONTROL = 'bucket-owner-full-control';
+}

+ 337 - 0
plugins/akeeba-s3/src/Configuration.php

@@ -0,0 +1,337 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+/**
+ * Holds the Amazon S3 confiugration credentials
+ */
+class Configuration
+{
+	/**
+	 * Access Key
+	 *
+	 * @var  string
+	 */
+	protected $access = '';
+
+	/**
+	 * Secret Key
+	 *
+	 * @var  string
+	 */
+	protected $secret = '';
+
+	/**
+	 * Security token. This is only required with temporary credentials provisioned by an EC2 instance.
+	 *
+	 * @var  string
+	 */
+	protected $token = '';
+
+	/**
+	 * Signature calculation method ('v2' or 'v4')
+	 *
+	 * @var  string
+	 */
+	protected $signatureMethod = 'v2';
+
+	/**
+	 * AWS region, used for v4 signatures
+	 *
+	 * @var  string
+	 */
+	protected $region = 'us-east-1';
+
+	/**
+	 * Should I use SSL (HTTPS) to communicate to Amazon S3?
+	 *
+	 * @var  bool
+	 */
+	protected $useSSL = true;
+
+	/**
+	 * Should I use legacy, path-style access to the bucket? When it's turned off (default) we use virtual hosting style
+	 * paths which are RECOMMENDED BY AMAZON per http://docs.aws.amazon.com/AmazonS3/latest/API/APIRest.html
+	 *
+	 * @var  bool
+	 */
+	protected $useLegacyPathStyle = false;
+
+	/**
+	 * Amazon S3 endpoint. You can use a custom endpoint with v2 signatures to access third party services which offer
+	 * S3 compatibility, e.g. OwnCloud, Google Storage etc.
+	 *
+	 * @var  string
+	 */
+	protected $endpoint = 's3.amazonaws.com';
+
+	/**
+	 * Public constructor
+	 *
+	 * @param   string  $access            Amazon S3 Access Key
+	 * @param   string  $secret            Amazon S3 Secret Key
+	 * @param   string  $singatureMethod   Signature method (v2 or v4)
+	 * @param   string  $region            Region, only required for v4 signatures
+	 */
+	function __construct($access, $secret, $singatureMethod = 'v2', $region = '')
+	{
+		$this->setAccess($access);
+		$this->setSecret($secret);
+		$this->setSignatureMethod($singatureMethod);
+		$this->setRegion($region);
+	}
+
+	/**
+	 * Get the Amazon access key
+	 *
+	 * @return  string
+	 */
+	public function getAccess()
+	{
+		return $this->access;
+	}
+
+	/**
+	 * Set the Amazon access key
+	 *
+	 * @param   string  $access  The access key to set
+	 *
+	 * @throws  Exception\InvalidAccessKey
+	 */
+	public function setAccess($access)
+	{
+		if (empty($access))
+		{
+			throw new Exception\InvalidAccessKey;
+		}
+
+		$this->access = $access;
+	}
+
+	/**
+	 * Get the Amazon secret key
+	 *
+	 * @return string
+	 */
+	public function getSecret()
+	{
+		return $this->secret;
+	}
+
+	/**
+	 * Set the Amazon secret key
+	 *
+	 * @param   string  $secret  The secret key to set
+	 *
+	 * @throws  Exception\InvalidSecretKey
+	 */
+	public function setSecret($secret)
+	{
+		if (empty($secret))
+		{
+			throw new Exception\InvalidSecretKey;
+		}
+
+		$this->secret = $secret;
+	}
+
+	/**
+	 * Return the security token. Only for temporary credentials provisioned through an EC2 instance.
+	 *
+	 * @return  string
+	 */
+	public function getToken()
+	{
+		return $this->token;
+	}
+
+	/**
+	 * Set the security token. Only for temporary credentials provisioned through an EC2 instance.
+	 *
+	 * @param  string  $token
+	 */
+	public function setToken($token)
+	{
+		$this->token = $token;
+	}
+
+	/**
+	 * Get the signature method to use
+	 *
+	 * @return  string
+	 */
+	public function getSignatureMethod()
+	{
+		return $this->signatureMethod;
+	}
+
+	/**
+	 * Set the signature method to use
+	 *
+	 * @param   string  $signatureMethod  One of v2 or v4
+	 *
+	 * @throws  Exception\InvalidSignatureMethod
+	 */
+	public function setSignatureMethod($signatureMethod)
+	{
+		$signatureMethod = strtolower($signatureMethod);
+		$signatureMethod = trim($signatureMethod);
+
+		if (!in_array($signatureMethod, array('v2', 'v4')))
+		{
+			throw new Exception\InvalidSignatureMethod;
+		}
+
+		// If you switch to v2 signatures we unset the region.
+		if ($signatureMethod == 'v2')
+		{
+			$this->setRegion('');
+
+			/**
+			 * If we are using Amazon S3 proper (not a custom endpoint) we have to set path style access to false.
+			 * Amazon S3 does not support v2 signatures with path style access at all (it returns an error telling
+			 * us to use the virtual hosting endpoint BUCKETNAME.s3.amazonaws.com).
+			 */
+			if (strpos($this->endpoint, 'amazonaws.com') !== false)
+			{
+				$this->setUseLegacyPathStyle(false);
+			}
+
+		}
+
+		$this->signatureMethod = $signatureMethod;
+	}
+
+	/**
+	 * Get the Amazon S3 region
+	 *
+	 * @return  string
+	 */
+	public function getRegion()
+	{
+		return $this->region;
+	}
+
+	/**
+	 * Set the Amazon S3 region
+	 *
+	 * @param   string  $region
+	 */
+	public function setRegion($region)
+	{
+		/**
+		 * You can only leave the region empty if you're using v2 signatures. Anything else gets you an exception.
+		 */
+		if (empty($region) && ($this->signatureMethod == 'v4'))
+		{
+			throw new Exception\InvalidRegion;
+		}
+
+		/**
+		 * Setting a Chinese-looking region force-changes the endpoint but ONLY if you were using the original Amazon S3
+		 * endpoint. If you're using a custom endpoint and provide a region with 'cn-' in its name we don't override
+		 * your custom endpoint.
+		 */
+		if (($this->endpoint == 's3.amazonaws.com') && (substr($region, 0, 3) == 'cn-'))
+		{
+			$this->setEndpoint('amazonaws.com.cn');
+		}
+
+		$this->region = $region;
+	}
+
+	/**
+	 * Is the connection to be made over HTTPS?
+	 *
+	 * @return  boolean
+	 */
+	public function isSSL()
+	{
+		return $this->useSSL;
+	}
+
+	/**
+	 * Set the connection SSL preference
+	 *
+	 * @param  boolean  $useSSL  True to use HTTPS
+	 */
+	public function setSSL($useSSL)
+	{
+		$this->useSSL = $useSSL ? true : false;
+	}
+
+	/**
+	 * Get the Amazon S3 endpoint
+	 *
+	 * @return  string
+	 */
+	public function getEndpoint()
+	{
+		return $this->endpoint;
+	}
+
+	/**
+	 * Set the Amazon S3 endpoint. Do NOT use a protocol
+	 *
+	 * @param   string  $endpoint  Custom endpoint, e.g. 's3.example.com' or 'www.example.com/s3api'
+	 */
+	public function setEndpoint($endpoint)
+	{
+		if (stristr($endpoint, '://'))
+		{
+			throw new Exception\InvalidEndpoint;
+		}
+
+		/**
+		 * If you set a custom endpoint we have to switch to v2 signatures since our v4 implementation only supports
+		 * Amazon endpoints.
+		 */
+		if ((strpos($endpoint, 'amazonaws.com') === false))
+		{
+			$this->setSignatureMethod('v2');
+		}
+
+		$this->endpoint = $endpoint;
+	}
+
+	/**
+	 * Should I use legacy, path-style access to the bucket? You should only use it with custom endpoints. Amazon itself
+	 * does not support path-style access since September 2020.
+	 *
+	 * @return  boolean
+	 */
+	public function getUseLegacyPathStyle()
+	{
+		return $this->useLegacyPathStyle;
+	}
+
+	/**
+	 * Set the flag for using legacy, path-style access to the bucket
+	 *
+	 * @param  boolean  $useLegacyPathStyle
+	 */
+	public function setUseLegacyPathStyle($useLegacyPathStyle)
+	{
+		$this->useLegacyPathStyle = $useLegacyPathStyle;
+
+		/**
+		 * If we are using Amazon S3 proper (not a custom endpoint) we have to set path style access to false.
+		 * Amazon S3 does not support v2 signatures with path style access at all (it returns an error telling
+		 * us to use the virtual hosting endpoint BUCKETNAME.s3.amazonaws.com).
+		 */
+		if ((strpos($this->endpoint, 'amazonaws.com') !== false) && ($this->signatureMethod == 'v2'))
+		{
+			$this->useLegacyPathStyle = false;
+		}
+	}
+}

+ 935 - 0
plugins/akeeba-s3/src/Connector.php

@@ -0,0 +1,935 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4;
+
+// Protection against direct access
+use Akeeba\Engine\Postproc\Connector\S3v4\Exception\CannotDeleteFile;
+use Akeeba\Engine\Postproc\Connector\S3v4\Exception\CannotGetBucket;
+use Akeeba\Engine\Postproc\Connector\S3v4\Exception\CannotGetFile;
+use Akeeba\Engine\Postproc\Connector\S3v4\Exception\CannotListBuckets;
+use Akeeba\Engine\Postproc\Connector\S3v4\Exception\CannotOpenFileForWrite;
+use Akeeba\Engine\Postproc\Connector\S3v4\Exception\CannotPutFile;
+use Akeeba\Engine\Postproc\Connector\S3v4\Response\Error;
+
+defined('AKEEBAENGINE') or die();
+
+class Connector
+{
+	/**
+	 * Amazon S3 configuration object
+	 *
+	 * @var  Configuration
+	 */
+	private $configuration = null;
+
+	/**
+	 * Connector constructor.
+	 *
+	 * @param   Configuration   $configuration  The configuration object to use
+	 */
+	public function __construct(Configuration $configuration)
+	{
+		$this->configuration = $configuration;
+	}
+
+	/**
+	 * Put an object to Amazon S3, i.e. upload a file. If the object already exists it will be overwritten.
+	 *
+	 * @param   Input   $input           Input object
+	 * @param   string  $bucket          Bucket name. If you're using v4 signatures it MUST be on the region defined.
+	 * @param   string  $uri             Object URI. Think of it as the absolute path of the file in the bucket.
+	 * @param   string  $acl             ACL constant, by default the object is private (visible only to the uploading user)
+	 * @param   array   $requestHeaders  Array of request headers
+	 *
+	 * @return  void
+	 *
+	 * @throws  CannotPutFile  If the upload is not possible
+	 */
+	public function putObject(Input $input, $bucket, $uri, $acl = Acl::ACL_PRIVATE, $requestHeaders = array())
+	{
+		$request = new Request('PUT', $bucket, $uri, $this->configuration);
+		$request->setInput($input);
+
+		// Custom request headers (Content-Type, Content-Disposition, Content-Encoding)
+		if (count($requestHeaders))
+		{
+			foreach ($requestHeaders as $h => $v)
+			{
+				if (strtolower(substr($h, 0, 6)) == 'x-amz-')
+				{
+					$request->setAmzHeader(strtolower($h), $v);
+				}
+				else
+				{
+					$request->setHeader($h, $v);
+				}
+			}
+		}
+
+		if (isset($requestHeaders['Content-Type']))
+		{
+			$input->setType($requestHeaders['Content-Type']);
+		}
+
+		if (($input->getSize() <= 0) || (($input->getInputType() == Input::INPUT_DATA) && (!strlen($input->getDataReference()))))
+		{
+			throw new CannotPutFile('Missing input parameters', 0);
+		}
+
+		// We need to post with Content-Length and Content-Type, MD5 is optional
+		$request->setHeader('Content-Type', $input->getType());
+		$request->setHeader('Content-Length', $input->getSize());
+
+		if ($input->getMd5sum())
+		{
+			$request->setHeader('Content-MD5', $input->getMd5sum());
+		}
+
+		$request->setAmzHeader('x-amz-acl', $acl);
+
+		$response = $request->getResponse();
+
+		if ($response->code !== 200)
+		{
+			if (!$response->error->isError())
+			{
+				throw new CannotPutFile("Unexpected HTTP status {$response->code}", $response->code);
+			}
+
+			if (is_object($response->body) && ($response->body instanceof \SimpleXMLElement) && (strpos($input->getSize(), ',') === false))
+			{
+				// For some reason, trying to single part upload files on some hosts comes back with an inexplicable
+				// error from Amazon that we need to set Content-Length:5242880,5242880 instead of
+				// Content-Length:5242880 which is AGAINST Amazon's documentation. In this case we pass the header
+				// 'workaround-braindead-error-from-amazon' and retry. Uh, OK?
+				if (isset($response->body->CanonicalRequest))
+				{
+					$amazonsCanonicalRequest = (string)$response->body->CanonicalRequest;
+					$lines                   = explode("\n", $amazonsCanonicalRequest);
+
+					foreach ($lines as $line)
+					{
+						if (substr($line, 0, 15) != 'content-length:')
+						{
+							continue;
+						}
+
+						list($junk, $stupidAmazonDefinedContentLength) = explode(":", $line);
+
+						if (strpos($stupidAmazonDefinedContentLength, ',') !== false)
+						{
+							if (!isset($requestHeaders['workaround-braindead-error-from-amazon']))
+							{
+								$requestHeaders['workaround-braindead-error-from-amazon'] = 'you can\'t fix stupid';
+
+								$this->putObject($input, $bucket, $uri, $acl, $requestHeaders);
+
+								return;
+							}
+						}
+					}
+				}
+			}
+		}
+
+
+
+		if ($response->error->isError())
+		{
+			throw new CannotPutFile(
+				sprintf(__METHOD__ . "(): [%s] %s\n\nDebug info:\n%s", $response->error->getCode(), $response->error->getMessage(), print_r($response->body, true))
+			);
+		}
+	}
+
+	/**
+	 * Get (download) an object
+	 *
+	 * @param   string  $bucket  Bucket name
+	 * @param   string  $uri     Object URI
+	 * @param   mixed   $saveTo  Filename or resource to write to
+	 * @param   int     $from    Start of the download range, null to download the entire object
+	 * @param   int     $to      End of the download range, null to download the entire object
+	 *
+	 * @return  void|string  No return if $saveTo is specified; data as string otherwise
+	 *
+	 * @throws  CannotOpenFileForWrite
+	 * @throws  CannotGetFile
+	 */
+	public function getObject($bucket, $uri, $saveTo = false, $from = null, $to = null)
+	{
+		$request = new Request('GET', $bucket, $uri, $this->configuration);
+
+		$fp = null;
+
+		if (!is_resource($saveTo) && is_string($saveTo))
+		{
+			$fp = @fopen($saveTo, 'wb');
+
+			if ($fp === false)
+			{
+				throw new CannotOpenFileForWrite($saveTo);
+			}
+		}
+
+		if (is_resource($saveTo))
+		{
+			$fp = $saveTo;
+		}
+
+		if (is_resource($fp))
+		{
+			$request->setFp($fp);
+		}
+
+		// Set the range header
+		if ((!empty($from) && !empty($to)) || (!is_null($from) && !empty($to)))
+		{
+			$request->setHeader('Range', "bytes=$from-$to");
+		}
+
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && (($response->code !== 200) && ($response->code !== 206)))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			) ;
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotGetFile(
+				sprintf(__METHOD__ . "({$bucket}, {$uri}): [%s] %s\n\nDebug info:\n%s",
+					$response->error->getCode(), $response->error->getMessage(), print_r($response->body, true)),
+				$response->error->getCode()
+			);
+		}
+
+		if (!is_resource($fp))
+		{
+			return $response->body;
+		}
+
+		return null;
+	}
+
+	/**
+	 * Delete an object
+	 *
+	 * @param   string  $bucket  Bucket name
+	 * @param   string  $uri     Object URI
+	 *
+	 * @return  void
+	 */
+	public function deleteObject($bucket, $uri)
+	{
+		$request = new Request('DELETE', $bucket, $uri, $this->configuration);
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && ($response->code !== 204))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			) ;
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotDeleteFile(
+				sprintf(__METHOD__ . "({$bucket}, {$uri}): [%s] %s",
+					$response->error->getCode(), $response->error->getMessage()),
+				$response->error->getCode()
+			);
+		}
+	}
+
+	/**
+	 * Get a query string authenticated URL
+	 *
+	 * @param   string   $bucket      Bucket name
+	 * @param   string   $uri         Object URI
+	 * @param   integer  $lifetime    Lifetime in seconds
+	 * @param   boolean  $https       Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string
+	 */
+	public function getAuthenticatedURL($bucket, $uri, $lifetime = null, $https = false)
+	{
+		// Get a request from the URI and bucket
+		$questionmarkPos = strpos($uri, '?');
+		$query           = '';
+
+		if ($questionmarkPos !== false)
+		{
+			$query = substr($uri, $questionmarkPos + 1);
+			$uri   = substr($uri, 0, $questionmarkPos);
+		}
+
+
+		/**
+		 * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+		 * !!!!             DO NOT TOUCH THIS CODE. YOU WILL BREAK PRE-SIGNED URLS WITH v4 SIGNATURES.              !!!!
+		 * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+		 *
+		 * The following two lines seem weird and possibly extraneous at first glance. However, they are VERY important.
+		 * If you remove them pre-signed URLs for v4 signatures will break! That's because pre-signed URLs with v4
+		 * signatures follow different rules than with v2 signatures.
+		 *
+		 * Authenticated (pre-signed) URLs are always made against the generic S3 region endpoint, not the bucket's
+		 * virtual-hosting-style domain name. The bucket is always the first component of the path.
+		 *
+		 * For example, given a bucket called foobar and an object baz.txt in it we are pre-signing the URL
+		 * https://s3-eu-west-1.amazonaws.com/foobar/baz.txt, not
+		 * https://foobar.s3-eu-west-1.amazonaws.com/foobar/baz.txt (as we'd be doing with v2 signatures).
+		 *
+		 * The problem is that the Request object needs to be created before we can convey the intent (regular request
+		 * or generation of a pre-signed URL). As a result its constructor creates the (immutable) request URI solely
+		 * based on whether the Configuration object's getUseLegacyPathStyle() returns false or not.
+		 *
+		 * Since we want to request URI to contain the bucket name we need to tell the Request object's constructor that
+		 * we are creating a Request object for path-style access, i.e. the useLegacyPathStyle flag in the Configuration
+		 * object is true. Naturally, the default behavior being virtual-hosting-style access to buckets, this flag is
+		 * most likely **false**.
+		 *
+		 * Therefore we need to clone the Configuration object, set the flag to true and create a Request object using
+		 * the falsified Configuration object.
+		 *
+		 * Note that v2 signatures are not affected. In v2 we are always appending the bucket name to the path, despite
+		 * the fact that we include the bucket name in the domain name.
+		 *
+		 * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+		 * !!!!             DO NOT TOUCH THIS CODE. YOU WILL BREAK PRE-SIGNED URLS WITH v4 SIGNATURES.              !!!!
+		 * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+		 */
+		$newConfig = clone $this->configuration;
+		$newConfig->setUseLegacyPathStyle(true);
+
+		// Create the request object.
+		$uri       = str_replace('%2F', '/', rawurlencode($uri));
+		$request = new Request('GET', $bucket, $uri, $newConfig);
+
+		if ($query)
+		{
+			parse_str($query, $parameters);
+
+			if (count($parameters))
+			{
+				foreach ($parameters as $k => $v)
+				{
+					$request->setParameter($k, $v);
+				}
+			}
+		}
+
+		// Get the signed URI from the Request object
+		return $request->getAuthenticatedURL($lifetime, $https);
+	}
+
+	/**
+	 * Get the location (region) of a bucket. You need this to use the V4 API on that bucket!
+	 *
+	 * @param   string   $bucket  Bucket name
+	 *
+	 * @return  string
+	 */
+	public function getBucketLocation($bucket)
+	{
+		$request = new Request('GET', $bucket, '', $this->configuration);
+		$request->setParameter('location', null);
+
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && $response->code !== 200)
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotGetBucket(
+				sprintf(__METHOD__ . "(): [%s] %s", $response->error->getCode(), $response->error->getMessage()),
+				$response->error->getCode()
+			);
+		}
+
+		$result = 'us-east-1';
+
+		if ($response->hasBody())
+		{
+			$result = (string) $response->body;
+		}
+
+		switch ($result)
+		{
+			// "EU" is an alias for 'eu-west-1', however the canonical location name you MUST use is 'eu-west-1'
+			case 'EU':
+			case 'eu':
+				$result = 'eu-west-1';
+				break;
+
+			// If the bucket location is 'us-east-1' you get an empty string. @#$%^&*()!!
+			case '':
+				$result = 'us-east-1';
+				break;
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Get the contents of a bucket
+	 *
+	 * If maxKeys is null this method will loop through truncated result sets
+	 *
+	 * @param   string   $bucket                Bucket name
+	 * @param   string   $prefix                Prefix (directory)
+	 * @param   string   $marker                Marker (last file listed)
+	 * @param   string   $maxKeys               Maximum number of keys ("files" and "directories") to return
+	 * @param   string   $delimiter             Delimiter, typically "/"
+	 * @param   boolean  $returnCommonPrefixes  Set to true to return CommonPrefixes
+	 *
+	 * @return  array
+	 */
+	public function getBucket($bucket, $prefix = null, $marker = null, $maxKeys = null, $delimiter = '/', $returnCommonPrefixes = false)
+	{
+		$request = new Request('GET', $bucket, '', $this->configuration);
+
+		if (!empty($prefix))
+		{
+			$request->setParameter('prefix', $prefix);
+		}
+
+		if (!empty($marker))
+		{
+			$request->setParameter('marker', $marker);
+		}
+
+		if (!empty($maxKeys))
+		{
+			$request->setParameter('max-keys', $maxKeys);
+		}
+
+		if (!empty($delimiter))
+		{
+			$request->setParameter('delimiter', $delimiter);
+		}
+
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && $response->code !== 200)
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotGetBucket(
+				sprintf(__METHOD__ . "(): [%s] %s", $response->error->getCode(), $response->error->getMessage()),
+				$response->error->getCode()
+			);
+		}
+
+		$results = array();
+
+		$nextMarker = null;
+
+		if ($response->hasBody() && isset($response->body->Contents))
+		{
+			foreach ($response->body->Contents as $c)
+			{
+				$results[(string)$c->Key] = array(
+					'name' => (string)$c->Key,
+					'time' => strtotime((string)$c->LastModified),
+					'size' => (int)$c->Size,
+					'hash' => substr((string)$c->ETag, 1, -1)
+				);
+
+				$nextMarker = (string)$c->Key;
+			}
+		}
+
+		if ($returnCommonPrefixes && $response->hasBody() && isset($response->body->CommonPrefixes))
+		{
+			foreach ($response->body->CommonPrefixes as $c)
+			{
+				$results[(string)$c->Prefix] = array('prefix' => (string)$c->Prefix);
+			}
+		}
+
+		if ($response->hasBody() && isset($response->body->IsTruncated) &&
+			((string)$response->body->IsTruncated == 'false')
+		)
+		{
+			return $results;
+		}
+
+		if ($response->hasBody() && isset($response->body->NextMarker))
+		{
+			$nextMarker = (string)$response->body->NextMarker;
+		}
+
+		// Loop through truncated results if maxKeys isn't specified
+		if ($maxKeys == null && $nextMarker !== null && ((string)$response->body->IsTruncated == 'true'))
+		{
+			do
+			{
+				$request = new Request('GET', $bucket, '', $this->configuration);
+
+				if (!empty($prefix))
+				{
+					$request->setParameter('prefix', $prefix);
+				}
+
+				$request->setParameter('marker', $nextMarker);
+
+				if (!empty($delimiter))
+				{
+					$request->setParameter('delimiter', $delimiter);
+				}
+
+				try
+				{
+					$response = $request->getResponse();
+				}
+				catch (\Exception $e)
+				{
+					break;
+				}
+
+				if ($response->hasBody() && isset($response->body->Contents))
+				{
+					foreach ($response->body->Contents as $c)
+					{
+						$results[(string)$c->Key] = array(
+							'name' => (string)$c->Key,
+							'time' => strtotime((string)$c->LastModified),
+							'size' => (int)$c->Size,
+							'hash' => substr((string)$c->ETag, 1, -1)
+						);
+
+						$nextMarker = (string)$c->Key;
+					}
+				}
+
+				if ($returnCommonPrefixes && $response->hasBody() && isset($response->body->CommonPrefixes))
+				{
+					foreach ($response->body->CommonPrefixes as $c)
+					{
+						$results[(string)$c->Prefix] = array('prefix' => (string)$c->Prefix);
+					}
+				}
+
+				if ($response->hasBody() && isset($response->body->NextMarker))
+				{
+					$nextMarker = (string)$response->body->NextMarker;
+				}
+			}
+			while (!$response->error->isError() && (string)$response->body->IsTruncated == 'true');
+		}
+
+		return $results;
+	}
+
+	/**
+	 * Get a list of buckets
+	 *
+	 * @param   boolean  $detailed  Returns detailed bucket list when true
+	 *
+	 * @return  array
+	 */
+	public function listBuckets($detailed = false)
+	{
+		// When listing buckets with the AWSv4 signature method we MUST set the region to us-east-1. Don't ask...
+		$configuration = clone $this->configuration;
+		$configuration->setRegion('us-east-1');
+
+		$request = new Request('GET', '', '', $configuration);
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && (($response->code !== 200)))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			) ;
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotListBuckets(
+				sprintf(__METHOD__ . "(): [%s] %s", $response->error->getCode(), $response->error->getMessage()),
+				$response->error->getCode()
+			);
+		}
+
+		$results = array();
+
+		if (!isset($response->body->Buckets))
+		{
+			return $results;
+		}
+
+		if ($detailed)
+		{
+			if (isset($response->body->Owner, $response->body->Owner->ID, $response->body->Owner->DisplayName))
+			{
+				$results['owner'] = array(
+					'id' => (string)$response->body->Owner->ID,
+					'name' => (string)$response->body->Owner->DisplayName
+				);
+			}
+
+			$results['buckets'] = array();
+
+			foreach ($response->body->Buckets->Bucket as $b)
+			{
+				$results['buckets'][] = array(
+					'name' => (string)$b->Name,
+					'time' => strtotime((string)$b->CreationDate)
+				);
+			}
+		}
+		else
+		{
+			foreach ($response->body->Buckets->Bucket as $b)
+			{
+				$results[] = (string)$b->Name;
+			}
+		}
+
+		return $results;
+	}
+
+	/**
+	 * Start a multipart upload of an object
+	 *
+	 * @param   Input   $input           Input data
+	 * @param   string  $bucket          Bucket name
+	 * @param   string  $uri             Object URI
+	 * @param   string  $acl             ACL constant
+	 * @param   array   $requestHeaders  Array of request headers
+	 *
+	 * @return  string  The upload session ID (UploadId)
+	 */
+	public function startMultipart(Input $input, $bucket, $uri, $acl = Acl::ACL_PRIVATE, $requestHeaders = array())
+	{
+		$request = new Request('POST', $bucket, $uri, $this->configuration);
+		$request->setParameter('uploads', '');
+
+		// Custom request headers (Content-Type, Content-Disposition, Content-Encoding)
+		if (is_array($requestHeaders))
+		{
+			foreach ($requestHeaders as $h => $v)
+			{
+				if (strtolower(substr($h, 0, 6)) == 'x-amz-')
+				{
+					$request->setAmzHeader(strtolower($h), $v);
+				}
+				else
+				{
+					$request->setHeader($h, $v);
+				}
+			}
+		}
+
+		$request->setAmzHeader('x-amz-acl', $acl);
+
+		if (isset($requestHeaders['Content-Type']))
+		{
+			$input->setType($requestHeaders['Content-Type']);
+		}
+
+		$request->setHeader('Content-Type', $input->getType());
+
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && ($response->code !== 200))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotPutFile(
+				sprintf(__METHOD__ . "(): [%s] %s\n\nDebug info:\n%s", $response->error->getCode(), $response->error->getMessage(), print_r($response->body, true))
+			);
+		}
+
+		return (string)$response->body->UploadId;
+	}
+
+	/**
+	 * Uploads a part of a multipart object upload
+	 *
+	 * @param   Input   $input           Input data. You MUST specify the UploadID and PartNumber
+	 * @param   string  $bucket          Bucket name
+	 * @param   string  $uri             Object URI
+	 * @param   array   $requestHeaders  Array of request headers or content type as a string
+	 * @param   int     $chunkSize       Size of each upload chunk, in bytes. It cannot be less than 5242880 bytes (5Mb)
+	 *
+	 * @return  null|string  The ETag of the upload part of null if we have ran out of parts to upload
+	 */
+	public function uploadMultipart(Input $input, $bucket, $uri, $requestHeaders = array(), $chunkSize  = 5242880)
+	{
+		if ($chunkSize < 5242880)
+		{
+			$chunkSize = 5242880;
+		}
+
+		// We need a valid UploadID and PartNumber
+		$UploadID = $input->getUploadID();
+		$PartNumber = $input->getPartNumber();
+
+		if (empty($UploadID))
+		{
+			throw new CannotPutFile(
+				__METHOD__ . '(): No UploadID specified'
+			);
+		}
+
+		if (empty($PartNumber))
+		{
+			throw new CannotPutFile(
+				__METHOD__ . '(): No PartNumber specified'
+			);
+		}
+
+		$UploadID = urlencode($UploadID);
+		$PartNumber = (int)$PartNumber;
+
+		$request = new Request('PUT', $bucket, $uri, $this->configuration);
+		$request->setParameter('partNumber', $PartNumber);
+		$request->setParameter('uploadId', $UploadID);
+		$request->setInput($input);
+
+		// Full data length
+		$totalSize = $input->getSize();
+
+		// No Content-Type for multipart uploads
+		$input->setType(null);
+
+		// Calculate part offset
+		$partOffset = $chunkSize * ($PartNumber - 1);
+
+		if ($partOffset > $totalSize)
+		{
+			// This is to signify that we ran out of parts ;)
+			return null;
+		}
+
+		// How many parts are there?
+		$totalParts = floor($totalSize / $chunkSize);
+
+		if ($totalParts * $chunkSize < $totalSize)
+		{
+			$totalParts++;
+		}
+
+		// Calculate Content-Length
+		$size = $chunkSize;
+
+		if ($PartNumber >= $totalParts)
+		{
+			$size = $totalSize - ($PartNumber - 1) * $chunkSize;
+		}
+
+		if ($size <= 0)
+		{
+			// This is to signify that we ran out of parts ;)
+			return null;
+		}
+
+		$input->setSize($size);
+
+		switch ($input->getInputType())
+		{
+			case Input::INPUT_DATA:
+				$input->setData(substr($input->getData(), ($PartNumber - 1) * $chunkSize, $input->getSize()));
+				break;
+
+			case Input::INPUT_FILE:
+			case Input::INPUT_RESOURCE:
+				$fp = $input->getFp();
+				fseek($fp, ($PartNumber - 1) * $chunkSize);
+				break;
+		}
+
+		// Custom request headers (Content-Type, Content-Disposition, Content-Encoding)
+		if (is_array($requestHeaders))
+		{
+			foreach ($requestHeaders as $h => $v)
+			{
+				if (strtolower(substr($h, 0, 6)) == 'x-amz-')
+				{
+					$request->setAmzHeader(strtolower($h), $v);
+				}
+				else
+				{
+					$request->setHeader($h, $v);
+				}
+			}
+		}
+
+		$request->setHeader('Content-Length', $input->getSize());
+
+		$response = $request->getResponse();
+
+		if ($response->code !== 200)
+		{
+			if (!$response->error->isError())
+			{
+				$response->error = new Error(
+					$response->code,
+					"Unexpected HTTP status {$response->code}"
+				);
+			}
+
+			if (is_object($response->body) && ($response->body instanceof \SimpleXMLElement) && (strpos($input->getSize(), ',') === false))
+			{
+				// For some moronic reason, trying to multipart upload files on some hosts comes back with a crazy
+				// error from Amazon that we need to set Content-Length:5242880,5242880 instead of
+				// Content-Length:5242880 which is AGAINST Amazon's documentation. In this case we pass the header
+				// 'workaround-braindead-error-from-amazon' and retry. Whatever.
+				if (isset($response->body->CanonicalRequest))
+				{
+					$amazonsCanonicalRequest = (string)$response->body->CanonicalRequest;
+					$lines = explode("\n", $amazonsCanonicalRequest);
+
+					foreach ($lines as $line)
+					{
+						if (substr($line, 0, 15) != 'content-length:')
+						{
+							continue;
+						}
+
+						list($junk, $stupidAmazonDefinedContentLength) = explode(":", $line);
+
+						if (strpos($stupidAmazonDefinedContentLength, ',') !== false)
+						{
+							if (!isset($requestHeaders['workaround-braindead-error-from-amazon']))
+							{
+								$requestHeaders['workaround-braindead-error-from-amazon'] = 'you can\'t fix stupid';
+
+								// This is required to reset the input size to its default value. If you don't do that
+								// only one part will ever be uploaded. Oops!
+								$input->setSize(-1);
+
+								return $this->uploadMultipart($input, $bucket, $uri, $requestHeaders, $chunkSize);
+							}
+						}
+					}
+				}
+
+			}
+
+			throw new CannotPutFile(
+				sprintf(__METHOD__ . "(): [%s] %s\n\nDebug info:\n%s", $response->error->getCode(), $response->error->getMessage(), print_r($response->body, true))
+			);
+		}
+
+		// Return the ETag header
+		return $response->headers['hash'];
+	}
+
+	/**
+	 * Finalizes the multi-part upload. The $input object should contain two keys, etags an array of ETags of the
+	 * uploaded parts and UploadID the multipart upload ID.
+	 *
+	 * @param   Input   $input   The array of input elements
+	 * @param   string  $bucket  The bucket where the object is being stored
+	 * @param   string  $uri     The key (path) to the object
+	 *
+	 * @return  void
+	 */
+	public function finalizeMultipart(Input $input, $bucket, $uri)
+	{
+		$etags = $input->getEtags();
+		$UploadID = $input->getUploadID();
+
+		if (empty($etags))
+		{
+			throw new CannotPutFile(
+				__METHOD__ . '(): No ETags array specified'
+			);
+		}
+
+		if (empty($UploadID))
+		{
+			throw new CannotPutFile(
+				__METHOD__ . '(): No UploadID specified'
+			);
+		}
+
+		// Create the message
+		$message = "<CompleteMultipartUpload>\n";
+		$part = 0;
+
+		foreach ($etags as $etag)
+		{
+			$part++;
+			$message .= "\t<Part>\n\t\t<PartNumber>$part</PartNumber>\n\t\t<ETag>\"$etag\"</ETag>\n\t</Part>\n";
+		}
+
+		$message .= "</CompleteMultipartUpload>";
+
+		// Get a request query
+		$reqInput = Input::createFromData($message);
+
+		$request = new Request('POST', $bucket, $uri, $this->configuration);
+		$request->setParameter('uploadId', $UploadID);
+		$request->setInput($reqInput);
+
+		// Do post
+		$request->setHeader('Content-Type', 'application/xml'); // Even though the Amazon API doc doesn't mention it, it's required... :(
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && ($response->code != 200))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			if ($response->error->getCode() == 'RequestTimeout')
+			{
+				return;
+			}
+
+			throw new CannotPutFile(
+				sprintf(__METHOD__ . "(): [%s] %s\n\nDebug info:\n%s", $response->error->getCode(), $response->error->getMessage(), print_r($response->body, true))
+			);
+		}
+	}
+
+	/**
+	 * Returns the configuration object
+	 *
+	 * @return  Configuration
+	 */
+	public function getConfiguration()
+	{
+		return $this->configuration;
+	}
+}

+ 17 - 0
plugins/akeeba-s3/src/Exception/CannotDeleteFile.php

@@ -0,0 +1,17 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+class CannotDeleteFile extends \RuntimeException
+{
+}

+ 17 - 0
plugins/akeeba-s3/src/Exception/CannotGetBucket.php

@@ -0,0 +1,17 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+class CannotGetBucket extends \RuntimeException
+{
+}

+ 17 - 0
plugins/akeeba-s3/src/Exception/CannotGetFile.php

@@ -0,0 +1,17 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+class CannotGetFile extends \RuntimeException
+{
+}

+ 17 - 0
plugins/akeeba-s3/src/Exception/CannotListBuckets.php

@@ -0,0 +1,17 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+class CannotListBuckets extends \RuntimeException
+{
+}

+ 26 - 0
plugins/akeeba-s3/src/Exception/CannotOpenFileForRead.php

@@ -0,0 +1,26 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Exception;
+
+class CannotOpenFileForRead extends \RuntimeException
+{
+	public function __construct($file = "", $code = 0, Exception $previous = null)
+	{
+		$message = "Cannot open $file for reading";
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 26 - 0
plugins/akeeba-s3/src/Exception/CannotOpenFileForWrite.php

@@ -0,0 +1,26 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Exception;
+
+class CannotOpenFileForWrite extends \RuntimeException
+{
+	public function __construct($file = "", $code = 0, Exception $previous = null)
+	{
+		$message = "Cannot open $file for writing";
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 17 - 0
plugins/akeeba-s3/src/Exception/CannotPutFile.php

@@ -0,0 +1,17 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+class CannotPutFile extends \RuntimeException
+{
+}

+ 21 - 0
plugins/akeeba-s3/src/Exception/ConfigurationError.php

@@ -0,0 +1,21 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+/**
+ * Configuration error
+ */
+abstract class ConfigurationError extends \RuntimeException
+{
+
+}

+ 32 - 0
plugins/akeeba-s3/src/Exception/InvalidAccessKey.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Exception;
+
+/**
+ * Invalid Amazon S3 access key
+ */
+class InvalidAccessKey extends ConfigurationError
+{
+	public function __construct($message = "", $code = 0, Exception $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The Amazon S3 Access Key provided is invalid';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
plugins/akeeba-s3/src/Exception/InvalidBody.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+use Exception;
+
+defined('AKEEBAENGINE') or die();
+
+/**
+ * Invalid response body type
+ */
+class InvalidBody extends \RuntimeException
+{
+	public function __construct($message = "", $code = 0, Exception $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'Invalid response body type';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
plugins/akeeba-s3/src/Exception/InvalidEndpoint.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Exception;
+
+/**
+ * Invalid Amazon S3 endpoint
+ */
+class InvalidEndpoint extends ConfigurationError
+{
+	public function __construct($message = "", $code = 0, Exception $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The custom S3 endpoint provided is invalid. Do NOT include the protocol (http:// or https://). Valid examples are s3.example.com and www.example.com/s3Api';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 29 - 0
plugins/akeeba-s3/src/Exception/InvalidFilePointer.php

@@ -0,0 +1,29 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Exception;
+
+class InvalidFilePointer extends \InvalidArgumentException
+{
+	public function __construct($message = "", $code = 0, Exception $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The specified file pointer is not a valid stream resource';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
plugins/akeeba-s3/src/Exception/InvalidRegion.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Exception;
+
+/**
+ * Invalid Amazon S3 region
+ */
+class InvalidRegion extends ConfigurationError
+{
+	public function __construct($message = "", $code = 0, Exception $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The Amazon S3 region provided is invalid.';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
plugins/akeeba-s3/src/Exception/InvalidSecretKey.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Exception;
+
+/**
+ * Invalid Amazon S3 secret key
+ */
+class InvalidSecretKey extends ConfigurationError
+{
+	public function __construct($message = "", $code = 0, Exception $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The Amazon S3 Secret Key provided is invalid';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
plugins/akeeba-s3/src/Exception/InvalidSignatureMethod.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Exception;
+
+/**
+ * Invalid Amazon S3 signature method
+ */
+class InvalidSignatureMethod extends ConfigurationError
+{
+	public function __construct($message = "", $code = 0, Exception $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The Amazon S3 signature method provided is invalid. Only v2 and v4 signatures are supported.';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 20 - 0
plugins/akeeba-s3/src/Exception/PropertyNotFound.php

@@ -0,0 +1,20 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+/**
+ * Invalid magic property name
+ */
+class PropertyNotFound extends \LogicException
+{
+}

+ 717 - 0
plugins/akeeba-s3/src/Input.php

@@ -0,0 +1,717 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+/**
+ * Defines an input source for PUT/POST requests to Amazon S3
+ */
+class Input
+{
+	/**
+	 * File pointer, in case we have a resource
+	 *
+	 * @var  resource
+	 */
+	private $fp  = null;
+
+	/**
+	 * Absolute filename to the file
+	 *
+	 * @var  string
+	 */
+	private $file = null;
+
+	/**
+	 * Data to upload, as a string
+	 *
+	 * @var  string
+	 */
+	private $data = null;
+
+	/**
+	 * Length of the data to upload
+	 *
+	 * @var  int
+	 */
+	private $size = -1;
+
+	/**
+	 * Content type (MIME type)
+	 *
+	 * @var  string
+	 */
+	private $type = '';
+
+	/**
+	 * MD5 sum of the data to upload, as base64 encoded string. If it's false no MD5 sum will be returned.
+	 *
+	 * @var  string|bool
+	 */
+	private $md5sum = null;
+
+	/**
+	 * SHA-256 sum of the data to upload, as lowercase hex string.
+	 *
+	 * @var  string
+	 */
+	private $sha256 = null;
+
+	/**
+	 * The Upload Session ID used for multipart uploads
+	 *
+	 * @var  string
+	 */
+	private $UploadID = null;
+
+	/**
+	 * The part number used in multipart uploads
+	 *
+	 * @var  null
+	 */
+	private $PartNumber = null;
+
+	/**
+	 * The list of ETags used when finalising a multipart upload
+	 *
+	 * @var  array
+	 */
+	private $etags = array();
+
+	/**
+	 * Input type: resource
+	 */
+	const INPUT_RESOURCE = 1;
+
+	/**
+	 * Input type: file
+	 */
+	const INPUT_FILE     = 2;
+
+	/**
+	 * Input type: raw data
+	 */
+	const INPUT_DATA     = 3;
+
+	/**
+	 * Create an input object from a file (also: any valid URL wrapper)
+	 *
+	 * @param   string        $file       Absolute file path or any valid URL fopen() wrapper
+	 * @param   bool|string   $md5sum     The MD5 sum. null to auto calculate, false to never calculate.
+	 * @param   null|string   $sha256sum  The SHA256 sum. null to auto calculate.
+	 *
+	 * @return  Input
+	 */
+	public static function createFromFile($file, $md5sum = false, $sha256sum = null)
+	{
+		$input = new Input();
+
+		$input->setFile($file);
+		$input->setMd5sum($md5sum);
+		$input->setSha256($sha256sum);
+
+		return $input;
+	}
+
+	/**
+	 * Create an input object from a stream resource / file pointer.
+	 *
+	 * Please note that the contentLength cannot be calcualted automatically unless you have a seekable stream resource.
+	 *
+	 * @param   resource      $resource       The file pointer or stream resource
+	 * @param   int           $contentLength  The length of the content in bytes. Set to -1 for auto calculation.
+	 * @param   bool|string   $md5sum         The MD5 sum. null to auto calculate, false to never calculate.
+	 * @param   null|string   $sha256sum      The SHA256 sum. null to auto calculate.
+	 *
+	 * @return  Input
+	 */
+	public static function createFromResource(&$resource, $contentLength, $md5sum = false, $sha256sum = null)
+	{
+		$input = new Input();
+
+		$input->setFp($resource);
+		$input->setSize($contentLength);
+		$input->setMd5sum($md5sum);
+		$input->setSha256($sha256sum);
+
+		return $input;
+	}
+
+	/**
+	 * Destructor.
+	 */
+	function __destruct()
+	{
+		if (is_resource($this->fp))
+		{
+			@fclose($this->fp);
+		}
+	}
+
+	/**
+	 * Create an input object from raw data.
+	 *
+	 * Please bear in mind that the data is being duplicated in memory. Therefore you'll need at least 2xstrlen($data)
+	 * of free memory when you are using this method. You can instantiate an object and use assignData to work around
+	 * this limitation when handling large amounts of data which may cause memory outages (typically: over 10Mb).
+	 *
+	 * @param   string        $data       The data to use.
+	 * @param   bool|string   $md5sum     The MD5 sum. null to auto calculate, false to never calculate.
+	 * @param   null|string   $sha256sum  The SHA256 sum. null to auto calculate.
+	 *
+	 * @return  Input
+	 */
+	public static function createFromData(&$data, $md5sum = false, $sha256sum = null)
+	{
+		$input = new Input();
+
+		$input->setData($data);
+		$input->setMd5sum($md5sum);
+		$input->setSha256($sha256sum);
+
+		return $input;
+	}
+
+	/**
+	 * Returns the input type (resource, file or data)
+	 *
+	 * @return  int
+	 */
+	public function getInputType()
+	{
+		if (!empty($this->file))
+		{
+			return self::INPUT_FILE;
+		}
+
+		if (!empty($this->fp))
+		{
+			return self::INPUT_RESOURCE;
+		}
+
+		return self::INPUT_DATA;
+	}
+
+	/**
+	 * Return the file pointer to the data, or null if this is not a resource input
+	 *
+	 * @return  resource|null
+	 */
+	public function getFp()
+	{
+		if (!is_resource($this->fp))
+		{
+			return null;
+		}
+
+		return $this->fp;
+	}
+
+	/**
+	 * Set the file pointer (or, generally, stream resource)
+	 *
+	 * @param   resource  $fp
+	 */
+	public function setFp($fp)
+	{
+		if (!is_resource($fp))
+		{
+			throw new Exception\InvalidFilePointer('$fp is not a file resource');
+		}
+
+		$this->fp = $fp;
+	}
+
+	/**
+	 * Get the absolute path to the input file, or null if this is not a file input
+	 *
+	 * @return  string|null
+	 */
+	public function getFile()
+	{
+		if (empty($this->file))
+		{
+			return null;
+		}
+
+		return $this->file;
+	}
+
+	/**
+	 * Set the asbolute path to the input file
+	 *
+	 * @param   string  $file
+	 */
+	public function setFile($file)
+	{
+		$this->file = $file;
+		$this->data = null;
+
+		if (is_resource($this->fp))
+		{
+			@fclose($this->fp);
+		}
+
+		$this->fp = @fopen($file, 'rb');
+
+		if ($this->fp === false)
+		{
+			throw new Exception\CannotOpenFileForRead($file);
+		}
+	}
+
+	/**
+	 * Return the raw input data, or null if this is a file or stream input
+	 *
+	 * @return  string
+	 */
+	public function getData()
+	{
+		if (empty($this->data) && ($this->getInputType() != self::INPUT_DATA))
+		{
+			return null;
+		}
+
+		return $this->data;
+	}
+
+	/**
+	 * Return a reference to the raw input data
+	 *
+	 * @return  string
+	 */
+	public function &getDataReference()
+	{
+		if (empty($this->data) && ($this->getInputType() != self::INPUT_DATA))
+		{
+			$this->data = null;
+		}
+
+		return $this->data;
+	}
+
+	/**
+	 * Set the raw input data
+	 *
+	 * @param   string  $data
+	 */
+	public function setData($data)
+	{
+		$this->data = $data;
+
+		if (is_resource($this->fp))
+		{
+			@fclose($this->fp);
+		}
+
+		$this->file = null;
+		$this->fp   = null;
+	}
+
+	/**
+	 * Set the raw input data by doing an assignment instead of memory copy. While this conserves memory you cannot use
+	 * this with hardcoded strings, method results etc without going through a variable first.
+	 *
+	 * @param   string  $data
+	 */
+	public function assignData(&$data)
+	{
+		$this->data = $data;
+
+		if (is_resource($this->fp))
+		{
+			@fclose($this->fp);
+		}
+
+		$this->file = null;
+		$this->fp   = null;
+	}
+
+	/**
+	 * Returns the size of the data to be uploaded, in bytes. If it's not already specified it will try to guess.
+	 *
+	 * @return  int
+	 */
+	public function getSize()
+	{
+		if ($this->size < 0)
+		{
+			$this->size = $this->getInputSize();
+		}
+
+		return $this->size;
+	}
+
+	/**
+	 * Set the size of the data to be uploaded.
+	 *
+	 * @param  int  $size
+	 */
+	public function setSize($size)
+	{
+		$this->size = $size;
+	}
+
+	/**
+	 * Get the MIME type of the data
+	 *
+	 * @return  string
+	 */
+	public function getType()
+	{
+		if (empty($this->type))
+		{
+			$this->type = 'application/octet-stream';
+
+			if ($this->getInputType() == self::INPUT_FILE)
+			{
+				$this->type = $this->getMimeType($this->file);
+			}
+		}
+
+		return $this->type;
+	}
+
+	/**
+	 * Set the MIME type of the data
+	 *
+	 * @param string $type
+	 */
+	public function setType($type)
+	{
+		$this->type = $type;
+	}
+
+	/**
+	 * Get the MD5 sum of the content
+	 *
+	 * @return  null|string
+	 */
+	public function getMd5sum()
+	{
+		if ($this->md5sum === false)
+		{
+			return null;
+		}
+
+		if (empty($this->md5sum))
+		{
+			$this->md5sum = $this->calculateMd5();
+		}
+
+		return $this->md5sum;
+	}
+
+	/**
+	 * Set the MD5 sum of the content. It must be a base64 encoded string of the raw MD5 binary value. Because Amazon.
+	 * Don't ask! You can also specify boolean false which means that we will never try to calculate an MD5 sum.
+	 *
+	 * @param  bool|string  $md5sum
+	 */
+	public function setMd5sum($md5sum)
+	{
+		$this->md5sum = $md5sum;
+	}
+
+	/**
+	 * Get the SHA-256 hash of the content
+	 *
+	 * @return  string
+	 */
+	public function getSha256()
+	{
+		if (empty($this->sha256))
+		{
+			$this->sha256 = $this->calculateSha256();
+		}
+
+		return $this->sha256;
+	}
+
+	/**
+	 * Set the SHA-256 sum of the content. It must be a lowercase hex string.
+	 *
+	 * @param   string  $sha256
+	 */
+	public function setSha256($sha256)
+	{
+		$this->sha256 = strtolower($sha256);
+	}
+
+	/**
+	 * Get the Upload Session ID for multipart uploads
+	 *
+	 * @return  string
+	 */
+	public function getUploadID()
+	{
+		return $this->UploadID;
+	}
+
+	/**
+	 * Set the Upload Session ID for multipart uploads
+	 *
+	 * @param   string  $UploadID
+	 */
+	public function setUploadID($UploadID)
+	{
+		$this->UploadID = $UploadID;
+	}
+
+	/**
+	 * Get the part number for multipart uploads
+	 *
+	 * @return  null|int
+	 */
+	public function getPartNumber()
+	{
+		return $this->PartNumber;
+	}
+
+	/**
+	 * Set the part number for multipart uploads
+	 *
+	 * @param   int  $PartNumber
+	 */
+	public function setPartNumber($PartNumber)
+	{
+		$this->PartNumber = (int)$PartNumber;
+	}
+
+	/**
+	 * Get the list of ETags for multipart uploads
+	 *
+	 * @return  array
+	 */
+	public function getEtags()
+	{
+		return $this->etags;
+	}
+
+	/**
+	 * Set the list of ETags for multipart uploads
+	 *
+	 * @param   array  $etags
+	 */
+	public function setEtags(array $etags)
+	{
+		$this->etags = $etags;
+	}
+
+	/**
+	 * Calculates the upload size from the input source. For data it's the entire raw string length. For a file resource
+	 * it's the entire file's length. For seekable stream resources it's the remaining data from the current seek
+	 * position to EOF.
+	 *
+	 * WARNING: You should never try to specify files or resources over 2Gb minus 1 byte otherwise 32-bit versions of
+	 * PHP (anything except Linux x64 builds) will fail in unpredictable ways: the internal int representation in PHP
+	 * depends on the target platform and is typically a signed 32-bit integer.
+	 *
+	 * @return  int
+	 */
+	private function getInputSize()
+	{
+		switch ($this->getInputType())
+		{
+			case self::INPUT_DATA:
+				return function_exists('mb_strlen') ? mb_strlen($this->data, '8bit') : strlen($this->data);
+				break;
+
+			case self::INPUT_FILE:
+				clearstatcache(true, $this->file);
+
+				return @filesize($this->file);
+				break;
+
+			case self::INPUT_RESOURCE:
+				$meta = stream_get_meta_data($this->fp);
+
+				if ($meta['seekable'])
+				{
+					$pos = ftell($this->fp);
+					$endPos = fseek($this->fp, 0, SEEK_END);
+					fseek($this->fp, $pos, SEEK_SET);
+
+					return $endPos - $pos + 1;
+				}
+
+				break;
+		}
+
+		return 0;
+	}
+
+	/**
+	 * Get the MIME type of a file
+	 *
+	 * @param   string  $file  The absolute path to the file for which we want to get the MIME type
+	 *
+	 * @return  string  The MIME type of the file
+	 */
+	private function getMimeType($file)
+	{
+		$type = false;
+
+		// Fileinfo documentation says fileinfo_open() will use the
+		// MAGIC env var for the magic file
+		if (extension_loaded('fileinfo') && isset($_ENV['MAGIC']) &&
+		    ($finfo = finfo_open(FILEINFO_MIME, $_ENV['MAGIC'])) !== false
+		)
+		{
+			if (($type = finfo_file($finfo, $file)) !== false)
+			{
+				// Remove the charset and grab the last content-type
+				$type = explode(' ', str_replace('; charset=', ';charset=', $type));
+				$type = array_pop($type);
+				$type = explode(';', $type);
+				$type = trim(array_shift($type));
+			}
+
+			finfo_close($finfo);
+		}
+		elseif (function_exists('mime_content_type'))
+		{
+			$type = trim(mime_content_type($file));
+		}
+
+		if ($type !== false && strlen($type) > 0)
+		{
+			return $type;
+		}
+
+		// Otherwise do it the old fashioned way
+		static $exts = array(
+			'jpg'  => 'image/jpeg',
+			'gif'  => 'image/gif',
+			'png'  => 'image/png',
+			'tif'  => 'image/tiff',
+			'tiff' => 'image/tiff',
+			'ico'  => 'image/x-icon',
+			'swf'  => 'application/x-shockwave-flash',
+			'pdf'  => 'application/pdf',
+			'zip'  => 'application/zip',
+			'gz'   => 'application/x-gzip',
+			'tar'  => 'application/x-tar',
+			'bz'   => 'application/x-bzip',
+			'bz2'  => 'application/x-bzip2',
+			'txt'  => 'text/plain',
+			'asc'  => 'text/plain',
+			'htm'  => 'text/html',
+			'html' => 'text/html',
+			'css'  => 'text/css',
+			'js'   => 'text/javascript',
+			'xml'  => 'text/xml',
+			'xsl'  => 'application/xsl+xml',
+			'ogg'  => 'application/ogg',
+			'mp3'  => 'audio/mpeg',
+			'wav'  => 'audio/x-wav',
+			'avi'  => 'video/x-msvideo',
+			'mpg'  => 'video/mpeg',
+			'mpeg' => 'video/mpeg',
+			'mov'  => 'video/quicktime',
+			'flv'  => 'video/x-flv',
+			'php'  => 'text/x-php'
+		);
+
+		$ext = strtolower(pathInfo($file, PATHINFO_EXTENSION));
+
+		return isset($exts[ $ext ]) ? $exts[ $ext ] : 'application/octet-stream';
+	}
+
+	/**
+	 * Calculate the MD5 sum of the input data
+	 *
+	 * @return  string  Base-64 encoded MD5 sum
+	 */
+	private function calculateMd5()
+	{
+		switch ($this->getInputType())
+		{
+			case self::INPUT_DATA:
+				return base64_encode(md5($this->data, true));
+				break;
+
+			case self::INPUT_FILE:
+				return base64_encode(md5_file($this->file, true));
+				break;
+
+			case self::INPUT_RESOURCE:
+				$ctx = hash_init('md5');
+				$pos = ftell($this->fp);
+				$size = $this->getSize();
+				$done = 0;
+				$batch = min(1048576, $size);
+
+				while ($done < $size)
+				{
+					$toRead = min($batch, $done - $size);
+					$data = @fread($this->fp, $toRead);
+					hash_update($ctx, $data);
+					unset($data);
+				}
+
+				fseek($this->fp, $pos, SEEK_SET);
+				return base64_encode(hash_final($ctx, true));
+
+				break;
+		}
+	}
+
+	/**
+	 * Calcualte the SHA256 data of the input data
+	 *
+	 * @return  string  Lowercase hex representation of the SHA-256 sum
+	 */
+	private function calculateSha256()
+	{
+		$inputType = $this->getInputType();
+		switch ($inputType)
+		{
+			case self::INPUT_DATA:
+				return hash('sha256', $this->data, false);
+				break;
+
+			case self::INPUT_FILE:
+			case self::INPUT_RESOURCE:
+				if ($inputType == self::INPUT_FILE)
+				{
+					$filesize = @filesize($this->file);
+					$fPos = @ftell($this->fp);
+
+					if (($filesize == $this->getSize()) && ($fPos === 0))
+					{
+						return hash_file('sha256', $this->file, false);
+					}
+				}
+
+				$ctx = hash_init('sha256');
+				$pos = ftell($this->fp);
+				$size = $this->getSize();
+				$done = 0;
+				$batch = min(1048576, $size);
+
+				while ($done < $size)
+				{
+					$toRead = min($batch, $size - $done);
+					$data = @fread($this->fp, $toRead);
+					$done += $toRead;
+					hash_update($ctx, $data);
+					unset($data);
+				}
+
+				fseek($this->fp, $pos, SEEK_SET);
+				return hash_final($ctx, false);
+
+				break;
+		}
+	}
+}

+ 755 - 0
plugins/akeeba-s3/src/Request.php

@@ -0,0 +1,755 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4;
+
+use Akeeba\Engine\Postproc\Connector\S3v4\Response\Error;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+
+class Request
+{
+	/**
+	 * The HTTP verb to use
+	 *
+	 * @var  string
+	 */
+	private $verb = 'GET';
+
+	/**
+	 * The bucket we are using
+	 *
+	 * @var  string
+	 */
+	private $bucket = '';
+
+	/**
+	 * The object URI, relative to the bucket's root
+	 *
+	 * @var  string
+	 */
+	private $uri = '';
+
+	/**
+	 * The remote resource we are querying
+	 *
+	 * @var  string
+	 */
+	private $resource = '';
+
+	/**
+	 * Query string parameters
+	 *
+	 * @var  array
+	 */
+	private $parameters = array();
+
+	/**
+	 * Amazon-specific headers to pass to the request
+	 *
+	 * @var  array
+	 */
+	private $amzHeaders = array();
+
+	/**
+	 * Regular HTTP headers to send in the request
+	 *
+	 * @var  array
+	 */
+	private $headers = array(
+		'Host' => '',
+		'Date' => '',
+		'Content-MD5' => '',
+		'Content-Type' => ''
+	);
+
+	/**
+	 * Input data for the request
+	 *
+	 * @var  Input
+	 */
+	private $input = null;
+
+	/**
+	 * The file resource we are writing data to
+	 *
+	 * @var  bool|resource
+	 */
+	private $fp = false;
+
+	/**
+	 * The Amazon S3 configuration object
+	 *
+	 * @var Configuration
+	 */
+	private $configuration = null;
+
+	/**
+	 * The response object
+	 *
+	 * @var  Response
+	 */
+	private $response = null;
+
+	/**
+	 * The location of the CA certificate cache. It can be a file or a directory. If it's not specified, the location
+	 * set in AKEEBA_CACERT_PEM will be used
+	 *
+	 * @var  string|null
+	 */
+	private $caCertLocation = null;
+
+	/**
+	 * Constructor
+	 *
+	 * @param   string         $verb           HTTP verb, e.g. 'POST'
+	 * @param   string         $bucket         Bucket name, e.g. 'example-bucket'
+	 * @param   string         $uri            Object URI
+	 * @param   Configuration  $configuration  The Amazon S3 configuration object to use
+	 *
+	 * @return  void
+	 */
+	function __construct($verb, $bucket = '', $uri = '', Configuration $configuration)
+	{
+		$this->verb          = $verb;
+		$this->bucket        = $bucket;
+		$this->uri           = '/';
+		$this->configuration = $configuration;
+
+		if (!empty($uri))
+		{
+			$this->uri = '/' . str_replace('%2F', '/', rawurlencode($uri));
+		}
+
+		$this->headers['Host'] = $this->getHostName($configuration, $this->bucket);
+		$this->resource        = $this->uri;
+
+		if (($this->bucket !== '') && $configuration->getUseLegacyPathStyle())
+		{
+			$this->resource = '/' . $this->bucket . $this->uri;
+
+			$this->uri = $this->resource;
+		}
+
+		// The date must always be added as a header
+		$this->headers['Date'] = gmdate('D, d M Y H:i:s O');
+
+		// If there is a security token we need to set up the X-Amz-Security-Token header
+		$token = $this->configuration->getToken();
+
+		if (!empty($token))
+		{
+			$this->setAmzHeader('x-amz-security-token', $token);
+		}
+
+		// Initialize the response object
+		$this->response = new Response();
+	}
+
+	/**
+	 * Get the input object
+	 *
+	 * @return  Input
+	 */
+	public function getInput()
+	{
+		return $this->input;
+	}
+
+	/**
+	 * Set the input object
+	 *
+	 * @param   Input  $input
+	 *
+	 * @return  void
+	 */
+	public function setInput(Input $input)
+	{
+		$this->input = $input;
+	}
+
+	/**
+	 * Set a request parameter
+	 *
+	 * @param   string  $key    The parameter name
+	 * @param   string  $value  The parameter value
+	 *
+	 * @return  void
+	 */
+	public function setParameter($key, $value)
+	{
+		$this->parameters[$key] = $value;
+	}
+
+	/**
+	 * Set a request header
+	 *
+	 * @param   string  $key    The header name
+	 * @param   string  $value  The header value
+	 *
+	 * @return  void
+	 */
+	public function setHeader($key, $value)
+	{
+		$this->headers[$key] = $value;
+	}
+
+	/**
+	 * Set an x-amz-meta-* header
+	 *
+	 * @param   string  $key    The header name
+	 * @param   string  $value  The header value
+	 *
+	 * @return  void
+	 */
+	public function setAmzHeader($key, $value)
+	{
+		$this->amzHeaders[$key] = $value;
+	}
+
+	/**
+	 * Get the HTTP verb of this request
+	 *
+	 * @return  string
+	 */
+	public function getVerb()
+	{
+		return $this->verb;
+	}
+
+	/**
+	 * Get the S3 bucket's name
+	 *
+	 * @return  string
+	 */
+	public function getBucket()
+	{
+		return $this->bucket;
+	}
+
+	/**
+	 * Get the absolute URI of the resource we're accessing
+	 *
+	 * @return  string
+	 */
+	public function getResource()
+	{
+		return $this->resource;
+	}
+
+	/**
+	 * Get the parameters array
+	 *
+	 * @return  array
+	 */
+	public function getParameters()
+	{
+		return $this->parameters;
+	}
+
+	/**
+	 * Get the Amazon headers array
+	 *
+	 * @return  array
+	 */
+	public function getAmzHeaders()
+	{
+		return $this->amzHeaders;
+	}
+
+	/**
+	 * Get the other headers array
+	 *
+	 * @return  array
+	 */
+	public function getHeaders()
+	{
+		return $this->headers;
+	}
+
+	/**
+	 * Get a reference to the Amazon configuration object
+	 *
+	 * @return  Configuration
+	 */
+	public function getConfiguration()
+	{
+		return $this->configuration;
+	}
+
+	/**
+	 * Get the file pointer resource (for PUT and POST requests)
+	 *
+	 * @return  bool|resource
+	 */
+	public function &getFp()
+	{
+		return $this->fp;
+	}
+
+	/**
+	 * Set the data resource as a file pointer
+	 *
+	 * @param   resource  $fp
+	 */
+	public function setFp($fp)
+	{
+		$this->fp = $fp;
+	}
+
+	/**
+	 * Get the certificate authority location
+	 *
+	 * @return  string
+	 */
+	public function getCaCertLocation()
+	{
+		if (!empty($this->caCertLocation))
+		{
+			return $this->caCertLocation;
+		}
+
+		if (defined('AKEEBA_CACERT_PEM'))
+		{
+			return AKEEBA_CACERT_PEM;
+		}
+
+		return null;
+	}
+
+	/**
+	 * @param null|string $caCertLocation
+	 */
+	public function setCaCertLocation($caCertLocation)
+	{
+		if (empty($caCertLocation))
+		{
+			$caCertLocation = null;
+		}
+
+		if (!is_null($caCertLocation) && !is_file($caCertLocation) && !is_dir($caCertLocation))
+		{
+			$caCertLocation = null;
+		}
+
+		$this->caCertLocation = $caCertLocation;
+	}
+
+	/**
+	 * Get a pre-signed URL for the request. Typically used to pre-sign GET requests to objects, i.e. give shareable
+	 * pre-authorized URLs for downloading files from S3.
+	 *
+	 * @param   integer  $lifetime    Lifetime in seconds
+	 * @param   boolean  $https       Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string  The presigned URL
+	 */
+	public function getAuthenticatedURL($lifetime = null, $https = false)
+	{
+		$this->processParametersIntoResource();
+		$signer = Signature::getSignatureObject($this, $this->configuration->getSignatureMethod());
+
+		return $signer->getAuthenticatedURL($lifetime, $https);
+	}
+
+	/**
+	 * Get the S3 response
+	 *
+	 * @return  Response
+	 */
+	public function getResponse()
+	{
+		$this->processParametersIntoResource();
+
+		$schema = 'http://';
+
+		if ($this->configuration->isSSL())
+		{
+			$schema = 'https://';
+		}
+
+		// Very special case. IF the URI ends in /?location AND the region is us-east-1 (Host is
+		// s3-external-1.amazonaws.com) THEN the host MUST become s3.amazonaws.com for the request to work. This is case
+		// of us not knowing the region of the bucket, therefore having to use a special endpoint which lets us query
+		// the region of the bucket without knowing its region. See
+		// http://stackoverflow.com/questions/27091816/retrieve-buckets-objects-without-knowing-buckets-region-with-aws-s3-rest-api
+		if ((substr($this->uri, - 10) == '/?location') && ($this->headers['Host'] == 's3-external-1.amazonaws.com'))
+		{
+			$this->headers['Host'] = 's3.amazonaws.com';
+		}
+
+		$url = $schema . $this->headers['Host'] . $this->uri;
+
+		// Basic setup
+		$curl = curl_init();
+		curl_setopt($curl, CURLOPT_USERAGENT, 'AkeebaBackupProfessional/S3PostProcessor');
+
+		if ($this->configuration->isSSL())
+		{
+			// Set the CA certificate cache location
+			$caCert = $this->getCaCertLocation();
+
+			if (!empty($caCert))
+			{
+				if (is_dir($caCert))
+				{
+					@curl_setopt($curl, CURLOPT_CAPATH, $caCert);
+				}
+				else
+				{
+					@curl_setopt($curl, CURLOPT_CAINFO, $caCert);
+				}
+			}
+
+			/**
+			 * Verify the host name in the certificate and the certificate itself.
+			 *
+			 * Caveat: if your bucket contains dots in the name we have to turn off host verification due to the way the
+			 * S3 SSL certificates are set up.
+			 */
+			$isAmazonS3 = (substr($this->headers['Host'], -14) == '.amazonaws.com') ||
+				substr($this->headers['Host'], -16) == 'amazonaws.com.cn';
+			$tooManyDots = substr_count($this->headers['Host'], '.') > 3;
+
+			$verifyHost = ($isAmazonS3 && $tooManyDots) ? 0 : 2;
+
+			curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, $verifyHost);
+			curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
+		}
+
+		curl_setopt($curl, CURLOPT_URL, $url);
+
+		$signer = Signature::getSignatureObject($this, $this->configuration->getSignatureMethod());
+		$signer->preProcessHeaders($this->headers, $this->amzHeaders);
+
+		// Headers
+		$headers = array();
+
+		foreach ($this->amzHeaders as $header => $value)
+		{
+			if (strlen($value) > 0)
+			{
+				$headers[] = $header . ': ' . $value;
+			}
+		}
+
+		foreach ($this->headers as $header => $value)
+		{
+			if (strlen($value) > 0)
+			{
+				$headers[] = $header . ': ' . $value;
+			}
+		}
+
+		$headers[] = 'Authorization: ' . $signer->getAuthorizationHeader();
+
+		curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
+		curl_setopt($curl, CURLOPT_HEADER, false);
+		curl_setopt($curl, CURLOPT_RETURNTRANSFER, false);
+		curl_setopt($curl, CURLOPT_WRITEFUNCTION, array($this, '__responseWriteCallback'));
+		curl_setopt($curl, CURLOPT_HEADERFUNCTION, array($this, '__responseHeaderCallback'));
+		curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
+
+		// Request types
+		switch ($this->verb)
+		{
+			case 'GET':
+				break;
+
+			case 'PUT':
+			case 'POST':
+				if (!is_object($this->input) || !($this->input instanceof Input))
+				{
+					$this->input = new Input();
+				}
+
+				$size = $this->input->getSize();
+				$type = $this->input->getInputType();
+
+				if ($type == Input::INPUT_DATA)
+				{
+					curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $this->verb);
+
+					$data = $this->input->getDataReference();
+
+					if (strlen($data))
+					{
+						curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
+					}
+
+					if ($size > 0)
+					{
+						curl_setopt($curl, CURLOPT_BUFFERSIZE, $size);
+					}
+				}
+				else
+				{
+					curl_setopt($curl, CURLOPT_PUT, true);
+					curl_setopt($curl, CURLOPT_INFILE, $this->input->getFp());
+
+					if ($size > 0)
+					{
+						curl_setopt($curl, CURLOPT_INFILESIZE, $size);
+					}
+				}
+
+
+				break;
+
+			case 'HEAD':
+				curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'HEAD');
+				curl_setopt($curl, CURLOPT_NOBODY, true);
+				break;
+
+			case 'DELETE':
+				curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE');
+				break;
+
+			default:
+				break;
+		}
+
+		// Execute, grab errors
+		$this->response->resetBody();
+
+		if (curl_exec($curl))
+		{
+			$this->response->code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+		}
+		else
+		{
+			$this->response->error = new Error(
+				curl_errno($curl),
+				curl_error($curl),
+				$this->resource
+			);
+		}
+
+		@curl_close($curl);
+
+		// Set the body data
+		$this->response->finaliseBody();
+
+		// Clean up file resources
+		if (!is_null($this->fp) && is_resource($this->fp))
+		{
+			fclose($this->fp);
+		}
+
+		return $this->response;
+	}
+
+
+	/**
+	 * cURL write callback
+	 *
+	 * @param   resource   &$curl  cURL resource
+	 * @param   string     &$data  Data
+	 *
+	 * @return  int  Length in bytes
+	 */
+	protected function  __responseWriteCallback(&$curl, &$data)
+	{
+		if (in_array($this->response->code, array(200, 206)) && !is_null($this->fp) && is_resource($this->fp))
+		{
+			return fwrite($this->fp, $data);
+		}
+
+		$this->response->addToBody($data);
+
+		return strlen($data);
+	}
+
+	/**
+	 * cURL header callback
+	 *
+	 * @param   resource  &$curl  cURL resource
+	 * @param   string    &$data  Data
+	 *
+	 * @return  int  Length in bytes
+	 */
+	protected function  __responseHeaderCallback(&$curl, &$data)
+	{
+		if (($strlen = strlen($data)) <= 2)
+		{
+			return $strlen;
+		}
+
+		if (substr($data, 0, 4) == 'HTTP')
+		{
+			$this->response->code = (int)substr($data, 9, 3);
+
+			return $strlen;
+		}
+
+		list($header, $value) = explode(': ', trim($data), 2);
+
+		switch (strtolower($header))
+		{
+			case 'last-modified':
+				$this->response->setHeader('time', strtotime($value));
+				break;
+
+			case 'content-length':
+				$this->response->setHeader('size', (int)$value);
+				break;
+
+			case 'content-type':
+				$this->response->setHeader('type', $value);
+				break;
+
+			case 'etag':
+				$this->response->setHeader('hash', $value[0] == '"' ? substr($value, 1, -1) : $value);
+				break;
+
+			default:
+				if (preg_match('/^x-amz-meta-.*$/', $header))
+				{
+					$this->setHeader($header, is_numeric($value) ? (int)$value : $value);
+				}
+				break;
+		}
+		return $strlen;
+	}
+
+	/**
+	 * Processes $this->parameters as a query string into $this->resource
+	 *
+	 * @return  void
+	 */
+	private function processParametersIntoResource()
+	{
+		if (count($this->parameters))
+		{
+			$query = substr($this->uri, -1) !== '?' ? '?' : '&';
+
+			ksort($this->parameters);
+
+			foreach ($this->parameters as $var => $value)
+			{
+				if ($value == null || $value == '')
+				{
+					$query .= $var . '&';
+				}
+				else
+				{
+					// Parameters must be URL-encoded
+					$query .= $var . '=' . rawurlencode($value) . '&';
+				}
+			}
+
+			$query = substr($query, 0, -1);
+			$this->uri .= $query;
+
+			if (array_key_exists('acl', $this->parameters) ||
+				array_key_exists('location', $this->parameters) ||
+				array_key_exists('torrent', $this->parameters) ||
+				array_key_exists('logging', $this->parameters) ||
+				array_key_exists('uploads', $this->parameters) ||
+				array_key_exists('uploadId', $this->parameters) ||
+				array_key_exists('partNumber', $this->parameters)
+			)
+			{
+				$this->resource .= $query;
+			}
+		}
+	}
+
+	/**
+	 * Get the region-specific hostname for an operation given a configuration and a bucket name. This ensures we can
+	 * always use an HTTPS connection, even with buckets containing dots in their names, without SSL certificate host
+	 * name validation issues.
+	 *
+	 * Please note that this requires the pathStyle flag to be set in Configuration because Amazon RECOMMENDS using the
+	 * virtual-hosted style request where applicable. See http://docs.aws.amazon.com/AmazonS3/latest/API/APIRest.html
+	 * Quoting this documentation:
+	 * "Although the path-style is still supported for legacy applications, we recommend using the virtual-hosted style
+	 * where applicable."
+	 *
+	 * @param   Configuration  $configuration
+	 * @param   string         $bucket
+	 *
+	 * @return  string
+	 */
+	private function getHostName(Configuration $configuration, $bucket)
+	{
+		// http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+		$endpoint = $configuration->getEndpoint();
+		$region   = $configuration->getRegion();
+
+		// If it's a bucket in China we need to use a different endpoint
+		if (($endpoint == 's3.amazonaws.com') && (substr($region, 0, 3) == 'cn-'))
+		{
+			$endpoint = 'amazonaws.com.cn';
+		}
+
+		/**
+		 * If there is no bucket we use the default endpoint, whatever it is. For Amazon S3 this format is only used
+		 * when we are making account-level, cross-region requests, e.g. list all buckets. For S3-compatible APIs it
+		 * depends on the API, but generally it's just for listing available buckets.
+		 */
+		if (empty($bucket))
+		{
+			return $endpoint;
+		}
+
+		/**
+		 * Are we using v2 signatures? In this case we use the endpoint defined by the user without translating it.
+		 */
+		if ($configuration->getSignatureMethod() != 'v4')
+		{
+			// Legacy path style: the hostname is the endpoint
+			if ($configuration->getUseLegacyPathStyle())
+			{
+				return $endpoint;
+			}
+
+			// Virtual hosting style: the hostname is the bucket, dot and endpoint.
+			return $bucket . '.' . $endpoint;
+		}
+
+		/**
+		 * When using the Amazon S3 with the v4 signature API we have to use a different hostname per region. The
+		 * mapping can be found in http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+		 *
+		 * This means changing the endpoint to s3-REGION.amazonaws.com with the following exceptions:
+		 * For us-east-1: s3-external-1.amazonaws.com
+		 * For China: s3.REGION.amazonaws.com.cn
+		 *
+		 * v4 signing does NOT support non-Amazon endpoints.
+		 */
+
+		// Most endpoints: s3-REGION.amazonaws.com
+		$endpoint = 's3-' . $region . '.amazonaws.com';
+
+		// Exception: US East 1 endpoint is s3-external-1.amazonaws.com, NOT s3-us-east-1.amazonaws.com
+		if ($region == 'us-east-1')
+		{
+			$endpoint = 's3-external-1.amazonaws.com';
+		}
+
+		// Exception: China
+		if (substr($region, 0, 3) == 'cn-')
+		{
+			// Chinese endpoint, e.g.: s3.cn-north-1.amazonaws.com.cn
+			$endpoint = 's3.' . $region . '.amazonaws.com.cn';
+		}
+
+		// Legacy path style access: return just the endpoint
+		if ($configuration->getUseLegacyPathStyle())
+		{
+			return $endpoint;
+		}
+
+		// Recommended virtual hosting access: bucket, dot, endpoint.
+		return $bucket . '.' . $endpoint;
+	}
+}

+ 337 - 0
plugins/akeeba-s3/src/Response.php

@@ -0,0 +1,337 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4;
+
+use Akeeba\Engine\Postproc\Connector\S3v4\Exception\PropertyNotFound;
+use Akeeba\Engine\Postproc\Connector\S3v4\Response\Error;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+/**
+ * Amazon S3 API response object
+ *
+ * @property   Error  $error    Response error object
+ * @property   mixed  $body     Body data
+ * @property   int    $code     Response code
+ * @property   array  $headers  Any headers we may have
+ */
+class Response
+{
+	/**
+	 * Error object
+	 *
+	 * @var  Error
+	 */
+	private $error = null;
+
+	/**
+	 * Response body
+	 *
+	 * @var  \SimpleXMLElement|string|null
+	 */
+	private $body = null;
+
+	/**
+	 * Status code of the response, e.g. 200 for OK, 403 for Forbidden etc
+	 *
+	 * @var  int
+	 */
+	private $code = 0;
+
+	/**
+	 * Response headers
+	 *
+	 * @var  array
+	 */
+	private $headers = array();
+
+	/**
+	 * Response constructor.
+	 */
+	public function __construct()
+	{
+		$this->error = new Error();
+	}
+
+	/**
+	 * Is this an error response?
+	 *
+	 * @return  bool
+	 */
+	public function isError()
+	{
+		return is_null($this->error) || $this->error->isError();
+	}
+
+	/**
+	 * Does this response have a body?
+	 *
+	 * @return  bool
+	 */
+	public function hasBody()
+	{
+		return !empty($this->body);
+	}
+
+	/**
+	 * Get the eresponse error object
+	 *
+	 * @return  Error
+	 */
+	public function getError()
+	{
+		return $this->error;
+	}
+
+	/**
+	 * Set the response error object
+	 *
+	 * @param   Error  $error
+	 */
+	public function setError(Error $error)
+	{
+		$this->error = $error;
+	}
+
+	/**
+	 * Get the response body
+	 *
+	 * @return null|string|\SimpleXMLElement
+	 */
+	public function getBody()
+	{
+		return $this->body;
+	}
+
+	/**
+	 * Set the response body. If it's a string we'll try to parse it as XML.
+	 *
+	 * @param   null|string|\SimpleXMLElement  $body
+	 */
+	public function setBody($body)
+	{
+		$this->body = null;
+
+		if (empty($body))
+		{
+			return;
+		}
+
+		$this->body = $body;
+
+		$this->finaliseBody();
+	}
+
+	public function resetBody()
+	{
+		$this->body = null;
+	}
+
+	public function addToBody($data)
+	{
+		if (empty($this->body))
+		{
+			$this->body = '';
+		}
+
+		$this->body .= $data;
+	}
+
+	public function finaliseBody()
+	{
+		if (!$this->hasBody())
+		{
+			return;
+		}
+
+		if (!isset($this->headers['type']))
+		{
+			$this->headers['type'] = 'text/plain';
+		}
+
+		if (is_string($this->body) &&
+			(($this->headers['type'] == 'application/xml') || (substr($this->body, 0, 5) == '<?xml'))
+		)
+		{
+			$this->body = simplexml_load_string($this->body);
+		}
+
+		if (is_object($this->body) && ($this->body instanceof \SimpleXMLElement))
+		{
+			$this->parseBody();
+		}
+	}
+
+	/**
+	 * Returns the status code of the response
+	 *
+	 * @return  int
+	 */
+	public function getCode()
+	{
+		return $this->code;
+	}
+
+	/**
+	 * Sets the status code of the response
+	 *
+	 * @param   int  $code
+	 */
+	public function setCode($code)
+	{
+		$this->code = (int) $code;
+	}
+
+	/**
+	 * Get the response headers
+	 *
+	 * @return  array
+	 */
+	public function getHeaders()
+	{
+		return $this->headers;
+	}
+
+	/**
+	 * Set the response headers
+	 *
+	 * @param   array  $headers
+	 */
+	public function setHeaders(array $headers)
+	{
+		$this->headers = $headers;
+	}
+
+	/**
+	 * Set a single header
+	 *
+	 * @param   string  $name   The header name
+	 * @param   mixed   $value  The header value
+	 *
+	 * @return  void
+	 */
+	public function setHeader($name, $value)
+	{
+		$this->headers[$name] = $value;
+	}
+
+	/**
+	 * Does a header by this name exist?
+	 *
+	 * @param   string  $name  The header to look for
+	 *
+	 * @return  bool  True if it exists
+	 */
+	public function hasHeader($name)
+	{
+		return array_key_exists($name, $this->headers);
+	}
+
+	/**
+	 * Unset a response header
+	 *
+	 * @param   string  $name  The header to unset
+	 *
+	 * @return  void
+	 */
+	public function unsetHeader($name)
+	{
+		if ($this->hasHeader($name))
+		{
+			unset ($this->headers[$name]);
+		}
+	}
+
+	/**
+	 * Magic getter for the protected properties
+	 *
+	 * @param   string  $name
+	 *
+	 * @return  mixed
+	 */
+	public function __get($name)
+	{
+		switch ($name)
+		{
+			case 'error':
+				return $this->getError();
+				break;
+
+			case 'body':
+				return $this->getBody();
+				break;
+
+			case 'code':
+				return $this->getCode();
+				break;
+
+			case 'headers':
+				return $this->getHeaders();
+				break;
+		}
+
+		throw new PropertyNotFound("Property $name not found in " . get_class($this));
+	}
+
+	/**
+	 * Magic setter for the protected properties
+	 *
+	 * @param   string  $name   The name of the property
+	 * @param   mixed   $value  The value of the property
+	 *
+	 * @return  void
+	 */
+	public function __set($name, $value)
+	{
+		switch ($name)
+		{
+			case 'error':
+				$this->setError($value);
+				break;
+
+			case 'body':
+				$this->setBody($value);
+				break;
+
+			case 'code':
+				$this->setCode($value);
+				break;
+
+			case 'headers':
+				$this->setHeaders($value);
+				break;
+
+			default:
+				throw new PropertyNotFound("Property $name not found in " . get_class($this));
+		}
+	}
+
+	/**
+	 * Scans the SimpleXMLElement body for errors and propagates them to the Error object
+	 */
+	protected function parseBody()
+	{
+		if (!in_array($this->code, array(200, 204)) &&
+			isset($this->body->Code, $this->body->Message)
+		)
+		{
+			$this->error = new Error(
+				(string)$this->body->Code,
+				(string)$this->body->Message
+			);
+
+			if (isset($this->body->Resource))
+			{
+				$this->error->setResource((string)$this->body->Resource);
+			}
+		}
+	}
+}

+ 139 - 0
plugins/akeeba-s3/src/Response/Error.php

@@ -0,0 +1,139 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Response;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+/**
+ * S3 response error object
+ */
+class Error
+{
+	/**
+	 * Error code
+	 *
+	 * @var  int
+	 */
+	private $code = 0;
+
+	/**
+	 * Error message
+	 *
+	 * @var  string
+	 */
+	private $message = '';
+
+	/**
+	 * URI to the resource that throws the error
+	 *
+	 * @var  string
+	 */
+	private $resource = '';
+
+	/**
+	 * Create a new error object
+	 *
+	 * @param   int     $code      The error code
+	 * @param   string  $message   The error message
+	 * @param   string  $resource  The URI to the resource throwing the error
+	 *
+	 * @return  Error
+	 */
+	function __construct($code = 0, $message = '', $resource = '')
+	{
+		$this->setCode($code);
+		$this->setMessage($message);
+		$this->setResource($resource);
+	}
+
+	/**
+	 * Get the error code
+	 *
+	 * @return  int
+	 */
+	public function getCode()
+	{
+		return $this->code;
+	}
+
+	/**
+	 * Set the error code
+	 *
+	 * @param  int  $code  Set to zeroo or a negative value to clear errors
+	 *
+	 * @return  void
+	 */
+	public function setCode($code)
+	{
+		if ($code <= 0)
+		{
+			$code = 0;
+			$this->setMessage('');
+			$this->setResource('');
+		}
+
+		$this->code = $code;
+	}
+
+	/**
+	 * Get the error message
+	 *
+	 * @return  string
+	 */
+	public function getMessage()
+	{
+		return $this->message;
+	}
+
+	/**
+	 * Set the error message
+	 *
+	 * @param  string  $message  The error message to set
+	 *
+	 * @return  void
+	 */
+	public function setMessage($message)
+	{
+		$this->message = $message;
+	}
+
+	/**
+	 * Get the URI of the resource throwing the error
+	 *
+	 * @return  string
+	 */
+	public function getResource()
+	{
+		return $this->resource;
+	}
+
+	/**
+	 * Set the URI of the resource throwing the error
+	 *
+	 * @param   string  $resource
+	 *
+	 * @return  void
+	 */
+	public function setResource($resource)
+	{
+		$this->resource = $resource;
+	}
+
+	/**
+	 * Do we actually have an error?
+	 *
+	 * @return  bool
+	 */
+	public function isError()
+	{
+		return ($this->code > 0) || !empty($this->message);
+	}
+}

+ 80 - 0
plugins/akeeba-s3/src/Signature.php

@@ -0,0 +1,80 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+/**
+ * Base class for request signing objects.
+ */
+abstract class Signature
+{
+	/**
+	 * The request we will be signing
+	 *
+	 * @var  Request
+	 */
+	protected $request = null;
+
+	/**
+	 * Signature constructor.
+	 *
+	 * @param   Request  $request  The request we will be signing
+	 */
+	public function __construct(Request $request)
+	{
+		$this->request = $request;
+	}
+
+	/**
+	 * Returns the authorization header for the request
+	 *
+	 * @return  string
+	 */
+	abstract public function getAuthorizationHeader();
+
+	/**
+	 * Pre-process the request headers before we convert them to cURL-compatible format. Used by signature engines to
+	 * add custom headers, e.g. x-amz-content-sha256
+	 *
+	 * @param   array  $headers     The associative array of headers to process
+	 * @param   array  $amzHeaders  The associative array of amz-* headers to process
+	 *
+	 * @return  void
+	 */
+	abstract public function preProcessHeaders(&$headers, &$amzHeaders);
+
+	/**
+	 * Get a pre-signed URL for the request. Typically used to pre-sign GET requests to objects, i.e. give shareable
+	 * pre-authorized URLs for downloading files from S3.
+	 *
+	 * @param   integer  $lifetime    Lifetime in seconds
+	 * @param   boolean  $https       Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string  The presigned URL
+	 */
+	abstract public function getAuthenticatedURL($lifetime = null, $https = false);
+
+	/**
+	 * Get a signature object for the request
+	 *
+	 * @param   Request  $request  The request which needs signing
+	 * @param   string   $method   The signature method, "v2" or "v4"
+	 *
+	 * @return  Signature
+	 */
+	public static function getSignatureObject(Request $request, $method = 'v2')
+	{
+		$className = '\\Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Signature\\' . ucfirst($method);
+
+		return new $className($request);
+	}
+}

+ 208 - 0
plugins/akeeba-s3/src/Signature/V2.php

@@ -0,0 +1,208 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Signature;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Akeeba\Engine\Postproc\Connector\S3v4\Signature;
+
+/**
+ * Implements the Amazon AWS v2 signatures
+ *
+ * @see http://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html
+ */
+class V2 extends Signature
+{
+	/**
+	 * Pre-process the request headers before we convert them to cURL-compatible format. Used by signature engines to
+	 * add custom headers, e.g. x-amz-content-sha256
+	 *
+	 * @param   array  $headers     The associative array of headers to process
+	 * @param   array  $amzHeaders  The associative array of amz-* headers to process
+	 *
+	 * @return  void
+	 */
+	public function preProcessHeaders(&$headers, &$amzHeaders)
+	{
+		// No pre-processing required for V2 signatures
+	}
+
+	/**
+	 * Get a pre-signed URL for the request. Typically used to pre-sign GET requests to objects, i.e. give shareable
+	 * pre-authorized URLs for downloading files from S3.
+	 *
+	 * @param   integer  $lifetime    Lifetime in seconds
+	 * @param   boolean  $https       Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string  The presigned URL
+	 */
+	public function getAuthenticatedURL($lifetime = null, $https = false)
+	{
+		// Set the Expires header
+		if (is_null($lifetime))
+		{
+			$lifetime = 10;
+		}
+
+		$expires = time() + $lifetime;
+		$this->request->setHeader('Expires', $expires);
+
+		$bucket    = $this->request->getBucket();
+		$uri       = $this->request->getResource();
+		$headers   = $this->request->getHeaders();
+		$accessKey = $this->request->getConfiguration()->getAccess();
+		$protocol  = $https ? 'https' : 'http';
+		$signature = $this->getAuthorizationHeader();
+
+		$search = '/' . $bucket;
+
+		if (strpos($uri, $search) === 0)
+		{
+			$uri = substr($uri, strlen($search));
+		}
+
+		$queryParameters = array_merge($this->request->getParameters(), array(
+			'AWSAccessKeyId'	=> $accessKey,
+			'Expires'			=> sprintf('%u', $expires),
+			'Signature'			=> $signature,
+		));
+
+		$query = http_build_query($queryParameters);
+		
+		// fix authenticated url for Google Cloud Storage - https://cloud.google.com/storage/docs/access-control/create-signed-urls-program
+		if ($this->request->getConfiguration()->getEndpoint() === "storage.googleapis.com") {
+		    // replace host with endpoint
+		    $headers['Host'] = 'storage.googleapis.com';
+		    // replace "AWSAccessKeyId" with "GoogleAccessId"
+		    $query = str_replace('AWSAccessKeyId', 'GoogleAccessId', $query);
+		    // add bucket to url
+		    $uri = '/' . $bucket . $uri;
+		}
+
+		$url = $protocol . '://' . $headers['Host'] . $uri;
+		$url .= (strpos($uri, '?') !== false) ? '&' : '?';
+		$url .= $query;
+
+		return $url;
+	}
+
+	/**
+	 * Returns the authorization header for the request
+	 *
+	 * @return  string
+	 */
+	public function getAuthorizationHeader()
+	{
+		$verb           = strtoupper($this->request->getVerb());
+		$resourcePath   = $this->request->getResource();
+		$headers        = $this->request->getHeaders();
+		$amzHeaders     = $this->request->getAmzHeaders();
+		$parameters     = $this->request->getParameters();
+		$bucket         = $this->request->getBucket();
+		$isPresignedURL = false;
+
+		$amz       = array();
+		$amzString = '';
+
+		// Collect AMZ headers for signature
+		foreach ($amzHeaders as $header => $value)
+		{
+			if (strlen($value) > 0)
+			{
+				$amz[] = strtolower($header) . ':' . $value;
+			}
+		}
+
+		// AMZ headers must be sorted and sent as separate lines
+		if (sizeof($amz) > 0)
+		{
+			sort($amz);
+			$amzString = "\n" . implode("\n", $amz);
+		}
+
+		// If the Expires query string parameter is set up we're pre-signing a download URL. The string to sign is a bit
+		// different in this case; it does not include the Date, it includes the Expires.
+		// See http://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html#RESTAuthenticationQueryStringAuth
+		if (isset($headers['Expires']))
+		{
+			$headers['Date'] = $headers['Expires'];
+			unset ($headers['Expires']);
+
+			$isPresignedURL = true;
+		}
+
+		/**
+		 * The resource path in S3 V2 signatures must ALWAYS contain the bucket name if a bucket is defined, even if we
+		 * are not using path-style access to the resource
+		 */
+		if (!empty($bucket) && !$this->request->getConfiguration()->getUseLegacyPathStyle())
+		{
+			$resourcePath = '/' . $bucket . $resourcePath;
+		}
+
+		$stringToSign = $verb . "\n" .
+			(isset($headers['Content-MD5']) ? $headers['Content-MD5'] : '') . "\n" .
+			(isset($headers['Content-Type']) ? $headers['Content-Type'] : '') . "\n" .
+			$headers['Date'] .
+			$amzString . "\n" .
+			$resourcePath;
+
+		// CloudFront only requires a date to be signed
+		if ($headers['Host'] == 'cloudfront.amazonaws.com')
+		{
+			$stringToSign = $headers['Date'];
+		}
+
+		$amazonV2Hash = $this->amazonV2Hash($stringToSign);
+
+		// For presigned URLs we only return the Base64-encoded signature without the AWS format specifier and the
+		// public access key.
+		if ($isPresignedURL)
+		{
+			return $amazonV2Hash;
+		}
+
+		return 'AWS ' .
+			$this->request->getConfiguration()->getAccess() . ':' .
+			$amazonV2Hash;
+	}
+
+	/**
+	 * Creates a HMAC-SHA1 hash. Uses the hash extension if present, otherwise falls back to slower, manual calculation.
+	 *
+	 * @param   string  $string  String to sign
+	 *
+	 * @return  string
+	 */
+	private function amazonV2Hash($string)
+	{
+		$secret = $this->request->getConfiguration()->getSecret();
+
+		if (extension_loaded('hash'))
+		{
+			$raw = hash_hmac('sha1', $string, $secret, true);
+
+			return base64_encode($raw);
+		}
+
+		$raw = pack('H*', sha1(
+					(str_pad($secret, 64, chr(0x00)) ^ (str_repeat(chr(0x5c), 64))) .
+					pack('H*', sha1(
+						(str_pad($secret, 64, chr(0x00)) ^ (str_repeat(chr(0x36), 64))) . $string
+						)
+					)
+				)
+		);
+
+		return base64_encode($raw);
+	}
+
+}

+ 379 - 0
plugins/akeeba-s3/src/Signature/V4.php

@@ -0,0 +1,379 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\Engine\Postproc\Connector\S3v4\Signature;
+
+// Protection against direct access
+defined('AKEEBAENGINE') or die();
+
+use Akeeba\Engine\Postproc\Connector\S3v4\Signature;
+
+/**
+ * Implements the Amazon AWS v4 signatures
+ *
+ * @see http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
+ */
+class V4 extends Signature
+{
+	/**
+	 * Pre-process the request headers before we convert them to cURL-compatible format. Used by signature engines to
+	 * add custom headers, e.g. x-amz-content-sha256
+	 *
+	 * @param   array  $headers     The associative array of headers to process
+	 * @param   array  $amzHeaders  The associative array of amz-* headers to process
+	 *
+	 * @return  void
+	 */
+	public function preProcessHeaders(&$headers, &$amzHeaders)
+	{
+		// Do we already have an SHA-256 payload hash?
+		if (isset($amzHeaders['x-amz-content-sha256']))
+		{
+			return;
+		}
+
+		// Set the payload hash header
+		$input = $this->request->getInput();
+
+		if (is_object($input))
+		{
+			$requestPayloadHash = $input->getSha256();
+		}
+		else
+		{
+			$requestPayloadHash = hash('sha256', '', false);
+		}
+
+		$amzHeaders['x-amz-content-sha256'] = $requestPayloadHash;
+	}
+
+	/**
+	 * Get a pre-signed URL for the request. Typically used to pre-sign GET requests to objects, i.e. give shareable
+	 * pre-authorized URLs for downloading files from S3.
+	 *
+	 * @param   integer  $lifetime    Lifetime in seconds
+	 * @param   boolean  $https       Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string  The presigned URL
+	 */
+	public function getAuthenticatedURL($lifetime = null, $https = false)
+	{
+		// Set the Expires header
+		if (is_null($lifetime))
+		{
+			$lifetime = 10;
+		}
+
+		/**
+		 * Authenticated URLs must always go through the generic regional endpoint, not the virtual hosting-style domain
+		 * name. This means that if you have a bucket "example" in the EU West 1 (Ireland) region we have to go through
+		 * http://s3-eu-west-1.amazonaws.com/example instead of http://example.amazonaws.com/ for all authenticated URLs
+		 */
+		$region   = $this->request->getConfiguration()->getRegion();
+		$hostname = $this->getPresignedHostnameForRegion($region);
+		$this->request->setHeader('Host', $hostname);
+
+		// Set the expiration time in seconds
+		$this->request->setHeader('Expires', (int) $lifetime);
+
+		// Get the query parameters, including the calculated signature
+		$bucket           = $this->request->getBucket();
+		$uri              = $this->request->getResource();
+		$headers          = $this->request->getHeaders();
+		$protocol         = $https ? 'https' : 'http';
+		$serialisedParams = $this->getAuthorizationHeader();
+
+		// The query parameters are returned serialized; unserialize them, then build and return the URL.
+		$queryParameters = unserialize($serialisedParams);
+
+		$query = http_build_query($queryParameters);
+
+		$url = $protocol . '://' . $headers['Host'] . $uri;
+		$url .= (strpos($uri, '?') !== false) ? '&' : '?';
+		$url .= $query;
+
+		return $url;
+	}
+
+	/**
+	 * Returns the authorization header for the request
+	 *
+	 * @return  string
+	 */
+	public function getAuthorizationHeader()
+	{
+		$verb           = strtoupper($this->request->getVerb());
+		$resourcePath   = $this->request->getResource();
+		$headers        = $this->request->getHeaders();
+		$amzHeaders     = $this->request->getAmzHeaders();
+		$parameters     = $this->request->getParameters();
+		$bucket         = $this->request->getBucket();
+		$isPresignedURL = false;
+
+		// See the Connector class for the explanation behind this ugly workaround
+		$amazonIsBraindead = isset($headers['workaround-braindead-error-from-amazon']);
+
+		if ($amazonIsBraindead)
+		{
+			unset ($headers['workaround-braindead-error-from-amazon']);
+		}
+
+		// Get the credentials scope
+		$signatureDate = new \DateTime($headers['Date']);
+
+		$credentialScope = $signatureDate->format('Ymd') . '/' .
+			$this->request->getConfiguration()->getRegion() . '/' .
+			's3/aws4_request';
+
+		/**
+		 * If the Expires header is set up we're pre-signing a download URL. The string to sign is a bit
+		 * different in this case and we have to pass certain headers as query string parameters.
+		 *
+		 * @see http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
+		 */
+		if (isset($headers['Expires']))
+		{
+			$gmtDate = clone $signatureDate;
+			$gmtDate->setTimezone(new \DateTimeZone('GMT'));
+
+			$parameters['X-Amz-Algorithm']      = "AWS4-HMAC-SHA256";
+			$parameters['X-Amz-Credential']     = $this->request->getConfiguration()->getAccess() . '/' . $credentialScope;
+			$parameters['X-Amz-Date']           = $gmtDate->format('Ymd\THis\Z');
+			$parameters['X-Amz-Expires']        = sprintf('%u', $headers['Expires']);
+			$token                              = $this->request->getConfiguration()->getToken();
+
+			if (!empty($token))
+			{
+				$parameters['x-amz-security-token'] = $token;
+			}
+
+			unset($headers['Expires']);
+			unset($headers['Date']);
+			unset($headers['Content-MD5']);
+			unset($headers['Content-Type']);
+
+			$isPresignedURL  = true;
+		}
+
+		// ========== Step 1: Create a canonical request ==========
+		// See http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+
+		$canonicalHeaders = "";
+		$signedHeadersArray = array();
+
+		// Calculate the canonical headers and the signed headers
+		if ($isPresignedURL)
+		{
+			// Presigned URLs use UNSIGNED-PAYLOAD instead
+			unset($amzHeaders['x-amz-content-sha256']);
+		}
+
+		$allHeaders = array_merge($headers, $amzHeaders);
+		ksort($allHeaders);
+
+		foreach ($allHeaders as $k => $v)
+		{
+			$lowercaseHeaderName = strtolower($k);
+
+			if ($amazonIsBraindead && ($lowercaseHeaderName == 'content-length'))
+			{
+				/**
+				 * I know it looks crazy. It is. Somehow Amazon requires me to do this and only on _some_ servers, mind
+				 * you. This is something undocumented and which is not covered by their official SDK. I had to write
+				 * my own library because of that and the official SDK's inability to upload large files without using
+				 * at least as much memory as the file itself (which doesn't fly well for files around 2Gb, let me tell
+				 * you that!).
+				 */
+				$v = "$v,$v";
+			}
+
+			$canonicalHeaders .= $lowercaseHeaderName . ':' . trim($v) . "\n";
+			$signedHeadersArray[] = $lowercaseHeaderName;
+		}
+
+		$signedHeaders = implode(';', $signedHeadersArray);
+
+		if ($isPresignedURL)
+		{
+			$parameters['X-Amz-SignedHeaders'] = $signedHeaders;
+		}
+
+		// The canonical URI is the resource path
+		$canonicalURI     = $resourcePath;
+		$bucketResource   = '/' . $bucket;
+		$regionalHostname = ($headers['Host'] != 's3.amazonaws.com') && ($headers['Host'] != $bucket . '.s3.amazonaws.com');
+
+		// Special case: if the canonical URI ends in /?location the bucket name DOES count as part of the canonical URL
+		// even though the Host is s3.amazonaws.com (in which case it normally shouldn't count). Yeah, I know, it makes
+		// no sense!!!
+		if (!$regionalHostname && ($headers['Host'] == 's3.amazonaws.com') && (substr($canonicalURI, -10) == '/?location'))
+		{
+			$regionalHostname = true;
+		}
+
+		if (!$regionalHostname && (strpos($canonicalURI, $bucketResource) === 0))
+		{
+			if ($canonicalURI === $bucketResource)
+			{
+				$canonicalURI = '/';
+			}
+			else
+			{
+				$canonicalURI = substr($canonicalURI, strlen($bucketResource));
+			}
+		}
+
+		// If the resource path has a query yank it and parse it into the parameters array
+		$questionMarkPos = strpos($canonicalURI, '?');
+
+		if ($questionMarkPos !== false)
+		{
+			$canonicalURI = substr($canonicalURI, 0, $questionMarkPos);
+			$queryString = @substr($canonicalURI, $questionMarkPos + 1);
+			@parse_str($queryString, $extraQuery);
+
+			if (count($extraQuery))
+			{
+				$parameters = array_merge($parameters, $extraQuery);
+			}
+		}
+
+		// The canonical query string is the string representation of $parameters, alpha sorted by key
+		ksort($parameters);
+
+		// We build the query the hard way because http_build_query in PHP 5.3 does NOT have the fourth parameter
+		// (encoding type), defaulting to RFC 1738 encoding whereas S3 expects RFC 3986 encoding
+		$canonicalQueryString = '';
+
+		if (!empty($parameters))
+		{
+			$temp = array();
+
+			foreach ($parameters as $k => $v)
+			{
+				$temp[] = $this->urlencode($k) . '=' . $this->urlencode($v);
+			}
+
+			$canonicalQueryString = implode('&', $temp);
+		}
+
+		// Get the payload hash
+		$requestPayloadHash = 'UNSIGNED-PAYLOAD';
+
+		if (isset($amzHeaders['x-amz-content-sha256']))
+		{
+			$requestPayloadHash = $amzHeaders['x-amz-content-sha256'];
+		}
+
+		// Calculate the canonical request
+		$canonicalRequest = $verb . "\n" .
+			$canonicalURI . "\n" .
+			$canonicalQueryString . "\n" .
+			$canonicalHeaders . "\n" .
+			$signedHeaders . "\n" .
+			$requestPayloadHash;
+
+		$hashedCanonicalRequest = hash('sha256', $canonicalRequest);
+
+		// ========== Step 2: Create a string to sign ==========
+		// See http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
+
+		if (!isset($headers['Date']))
+		{
+			$headers['Date'] = '';
+		}
+
+		$stringToSign = "AWS4-HMAC-SHA256\n" .
+			$headers['Date'] . "\n" .
+			$credentialScope . "\n" .
+			$hashedCanonicalRequest;
+
+		if ($isPresignedURL)
+		{
+			$stringToSign = "AWS4-HMAC-SHA256\n" .
+				$parameters['X-Amz-Date'] . "\n" .
+				$credentialScope . "\n" .
+				$hashedCanonicalRequest;
+		}
+
+		// ========== Step 3: Calculate the signature ==========
+		// See http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
+		$kSigning = $this->getSigningKey($signatureDate);
+
+		$signature = hash_hmac('sha256', $stringToSign, $kSigning, false);
+
+		// ========== Step 4: Add the signing information to the Request ==========
+		// See http://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
+
+		$authorization = 'AWS4-HMAC-SHA256 Credential=' .
+			$this->request->getConfiguration()->getAccess() . '/' . $credentialScope . ', ' .
+			'SignedHeaders=' . $signedHeaders . ', ' .
+			'Signature=' . $signature;
+
+		// For presigned URLs we only return the Base64-encoded signature without the AWS format specifier and the
+		// public access key.
+		if ($isPresignedURL)
+		{
+			$parameters['X-Amz-Signature'] = $signature;
+
+			return serialize($parameters);
+		}
+
+		return $authorization;
+	}
+
+	/**
+	 * Calculate the AWS4 signing key
+	 *
+	 * @param   \DateTime  $signatureDate  The date the signing key is good for
+	 *
+	 * @return  string
+	 */
+	private function getSigningKey(\DateTime $signatureDate)
+	{
+		$kSecret  = $this->request->getConfiguration()->getSecret();
+		$kDate    = hash_hmac('sha256', $signatureDate->format('Ymd'), 'AWS4' . $kSecret, true);
+		$kRegion  = hash_hmac('sha256', $this->request->getConfiguration()->getRegion(), $kDate, true);
+		$kService = hash_hmac('sha256', 's3', $kRegion, true);
+		$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
+
+		return $kSigning;
+	}
+
+	private function urlencode($string)
+	{
+		return str_replace('+', '%20', urlencode($string));
+	}
+
+	/**
+	 * Get the correct hostname for the given AWS region
+	 *
+	 * @param   string  $region
+	 *
+	 * @return  string
+	 */
+	private function getPresignedHostnameForRegion($region)
+	{
+		$endpoint = 's3.amazonaws.com';
+
+		if ($region == 'us-east-1')
+		{
+			$region = 'external-1';
+		}
+		elseif (substr($region, 0, 3) == 'cn-')
+		{
+			$endpoint = 'amazonaws.com.cn';
+
+			return 's3.' . $region . '.' . $endpoint;
+		}
+
+		return str_replace('s3', 's3-' . $region, $endpoint);
+	}
+
+}

+ 3 - 2
plugins/amazon-s3/S3.php

@@ -1182,8 +1182,9 @@ class S3
 		$expires = self::__getTime() + $lifetime;
 		$uri = str_replace(array('%2F', '%2B'), array('/', '+'), rawurlencode($uri));
 		return sprintf(($https ? 'https' : 'http').'://%s/%s?AWSAccessKeyId=%s&Expires=%u&Signature=%s',
-		$hostBucket ? $bucket : $bucket.'.s3.amazonaws.com', $uri, self::$__accessKey, $expires,
+		
 		// $hostBucket ? $bucket : self::$endpoint.'/'.$bucket, $uri, self::$__accessKey, $expires,
+        $hostBucket ? $bucket : $bucket . '.' . self::$endpoint, $uri, self::$__accessKey, $expires,
 		urlencode(self::__getHash("GET\n\n\n{$expires}\n/{$bucket}/{$uri}")));
 	}
 
@@ -2355,7 +2356,7 @@ final class S3Request
 			elseif ($header == 'Content-Type')
 				$this->response->headers['type'] = $value;
 			elseif ($header == 'ETag')
-				$this->response->headers['hash'] = $value{0} == '"' ? substr($value, 1, -1) : $value;
+				$this->response->headers['hash'] = $value[0] == '"' ? substr($value, 1, -1) : $value;
 			elseif (preg_match('/^x-amz-meta-.*$/', $header))
 				$this->response->headers[$header] = $value;
 		}

+ 7 - 3
samples/storage.php

@@ -57,9 +57,13 @@ function PageCompMainCode()
 
     $iProfileId = 123;
     BxDolStorage::pruning(); // pruning is needed to clear expired security tokens, you can call it on cron when your server is not busy
-    $oStorage = BxDolStorage::getObjectInstance('sample');
+    $oStorage = BxDolStorage::getObjectInstance('sys_files');
 
-    echo '<pre>reloadMimeTypesFromFile: [' . $oStorage->reloadMimeTypesFromFile(/*'/Users/alex/mime.types'*/'/etc/apache2/mime.types') . ']</pre>';
+    $aStorageObject = $oStorage->getObjectData();
+    echo "<b>Engine: " . $aStorageObject['engine'] . '</b><br />';
+    echo "<b>Endpoint: " . getParam('sys_storage_s3_endpoint') . '</b><br />';
+
+    // echo '<pre>reloadMimeTypesFromFile: [' . $oStorage->reloadMimeTypesFromFile(/*'/Users/alex/mime.types'*/'/etc/apache2/mime.types') . ']</pre>';
 
     if (isset($_POST['add'])) {
         $iId = $oStorage->storeFileFromForm($_FILES['file'], true, $iProfileId);
@@ -94,7 +98,7 @@ function PageCompMainCode()
     $a = $oStorage->getFilesAll();
     echo "<h2>Files List:</h2> <hr />";
     foreach ($a as $r)
-        echo $r['file_name'] . '(private:[' . $oStorage->isFilePrivate($r['id']) . ']) : <img src="' . $oStorage->getFileUrlById($r['id']) . '" /> <hr />';
+        echo $r['file_name'] . '(private:[' . $oStorage->isFilePrivate($r['id']) . ']) : <img src="' . $oStorage->getFileUrlById($r['id']) . '" style="max-width:150px;" /> <hr />';
 
     echo '<form method="POST">';
     foreach ($a as $r)