Add S3 Storage Backend

This commit is contained in:
Philipp 2022-02-20 21:22:07 +01:00
parent 95fcf98759
commit 9c4b12f868
No known key found for this signature in database
GPG key ID: 24A7501396EB5432
63 changed files with 8108 additions and 0 deletions

31
s3_storage/vendor/akeeba/s3/src/Acl.php vendored Normal file
View file

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

View file

@ -0,0 +1,366 @@
<?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 SSL (HTTPS) to communicate to Amazon S3?
*
* @var bool
*/
protected $useDualstackUrl = false;
/**
* 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 $signatureMethod Signature method (v2 or v4)
* @param string $region Region, only required for v4 signatures
*/
function __construct(string $access, string $secret, string $signatureMethod = 'v2', string $region = '')
{
$this->setAccess($access);
$this->setSecret($secret);
$this->setSignatureMethod($signatureMethod);
$this->setRegion($region);
}
/**
* Get the Amazon access key
*
* @return string
*/
public function getAccess(): string
{
return $this->access;
}
/**
* Set the Amazon access key
*
* @param string $access The access key to set
*
* @throws Exception\InvalidAccessKey
*/
public function setAccess(string $access): void
{
if (empty($access))
{
throw new Exception\InvalidAccessKey;
}
$this->access = $access;
}
/**
* Get the Amazon secret key
*
* @return string
*/
public function getSecret(): string
{
return $this->secret;
}
/**
* Set the Amazon secret key
*
* @param string $secret The secret key to set
*
* @throws Exception\InvalidSecretKey
*/
public function setSecret(string $secret): void
{
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(): string
{
return $this->token;
}
/**
* Set the security token. Only for temporary credentials provisioned through an EC2 instance.
*
* @param string $token
*/
public function setToken(string $token): void
{
$this->token = $token;
}
/**
* Get the signature method to use
*
* @return string
*/
public function getSignatureMethod(): string
{
return $this->signatureMethod;
}
/**
* Set the signature method to use
*
* @param string $signatureMethod One of v2 or v4
*
* @throws Exception\InvalidSignatureMethod
*/
public function setSignatureMethod(string $signatureMethod): void
{
$signatureMethod = strtolower($signatureMethod);
$signatureMethod = trim($signatureMethod);
if (!in_array($signatureMethod, ['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(): string
{
return $this->region;
}
/**
* Set the Amazon S3 region
*
* @param string $region
*/
public function setRegion(string $region): void
{
/**
* 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 bool
*/
public function isSSL(): bool
{
return $this->useSSL;
}
/**
* Set the connection SSL preference
*
* @param bool $useSSL True to use HTTPS
*/
public function setSSL(bool $useSSL): void
{
$this->useSSL = $useSSL ? true : false;
}
/**
* Get the Amazon S3 endpoint
*
* @return string
*/
public function getEndpoint(): string
{
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(string $endpoint): void
{
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
* is currently deprecating support for path-style access but has extended the migration date to an unknown
* time https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/
*
* @return bool
*/
public function getUseLegacyPathStyle(): bool
{
return $this->useLegacyPathStyle;
}
/**
* Set the flag for using legacy, path-style access to the bucket
*
* @param bool $useLegacyPathStyle
*/
public function setUseLegacyPathStyle(bool $useLegacyPathStyle): void
{
$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;
}
}
/**
* Should we use the dualstack URL (which will ship traffic over ipv6 in most cases). For more information on these
* endpoints please read https://docs.aws.amazon.com/AmazonS3/latest/dev/dual-stack-endpoints.html
*
* @return bool
*/
public function getDualstackUrl(): bool
{
return $this->useDualstackUrl;
}
/**
* Set the flag for using legacy, path-style access to the bucket
*
* @param bool $useDualstackUrl
*/
public function setUseDualstackUrl(bool $useDualstackUrl): void
{
$this->useDualstackUrl = $useDualstackUrl;
}
}

View file

@ -0,0 +1,961 @@
<?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, string $bucket, string $uri, string $acl = Acl::ACL_PRIVATE, array $requestHeaders = []): void
{
$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;
}
[$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 string|resource|null $saveTo Filename or resource to write to
* @param int|null $from Start of the download range, null to download the entire object
* @param int|null $to End of the download range, null to download the entire object
*
* @return string|null No return if $saveTo is specified; data as string otherwise
*
*/
public function getObject(string $bucket, string $uri, $saveTo = null, ?int $from = null, ?int $to = null): ?string
{
$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(string $bucket, string $uri): void
{
$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 int|null $lifetime Lifetime in seconds
* @param bool $https Use HTTPS ($hostBucket should be false for SSL verification)?
*
* @return string
*/
public function getAuthenticatedURL(string $bucket, string $uri, ?int $lifetime = null, bool $https = false): string
{
// 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(string $bucket): string
{
$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|null $prefix Prefix (directory)
* @param string|null $marker Marker (last file listed)
* @param int|null $maxKeys Maximum number of keys ("files" and "directories") to return
* @param string $delimiter Delimiter, typically "/"
* @param bool $returnCommonPrefixes Set to true to return CommonPrefixes
*
* @return array
*/
public function getBucket(string $bucket, ?string $prefix = null, ?string $marker = null, ?int $maxKeys = null, string $delimiter = '/', bool $returnCommonPrefixes = false): array
{
$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 = [];
$nextMarker = null;
if ($response->hasBody() && isset($response->body->Contents))
{
foreach ($response->body->Contents as $c)
{
$results[(string) $c->Key] = [
'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] = ['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;
}
// Is it a truncated result?
$isTruncated = ($nextMarker !== null) && ((string) $response->body->IsTruncated == 'true');
// Is this a truncated result and no maxKeys specified?
$isTruncatedAndNoMaxKeys = ($maxKeys == null) && $isTruncated;
// Is this a truncated result with less keys than the specified maxKeys; and common prefixes found but not returned to the caller?
$isTruncatedAndNeedsContinue = ($maxKeys != null) && $isTruncated && (count($results) < $maxKeys);
// Loop through truncated results if maxKeys isn't specified
if ($isTruncatedAndNoMaxKeys || $isTruncatedAndNeedsContinue)
{
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] = [
'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] = ['prefix' => (string) $c->Prefix];
}
}
if ($response->hasBody() && isset($response->body->NextMarker))
{
$nextMarker = (string) $response->body->NextMarker;
}
$continueCondition = false;
if ($isTruncatedAndNoMaxKeys)
{
$continueCondition = !$response->error->isError() && $isTruncated;
}
if ($isTruncatedAndNeedsContinue)
{
$continueCondition = !$response->error->isError() && $isTruncated && (count($results) < $maxKeys);
}
} while ($continueCondition);
}
if (!is_null($maxKeys))
{
$results = array_splice($results, 0, $maxKeys);
}
return $results;
}
/**
* Get a list of buckets
*
* @param bool $detailed Returns detailed bucket list when true
*
* @return array
*/
public function listBuckets(bool $detailed = false): array
{
// 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 = [];
if (!isset($response->body->Buckets))
{
return $results;
}
if ($detailed)
{
if (isset($response->body->Owner, $response->body->Owner->ID, $response->body->Owner->DisplayName))
{
$results['owner'] = [
'id' => (string) $response->body->Owner->ID,
'name' => (string) $response->body->Owner->DisplayName,
];
}
$results['buckets'] = [];
foreach ($response->body->Buckets->Bucket as $b)
{
$results['buckets'][] = [
'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, string $bucket, string $uri, string $acl = Acl::ACL_PRIVATE, array $requestHeaders = []): string
{
$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, string $bucket, string $uri, array $requestHeaders = [], int $chunkSize = 5242880): ?string
{
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());
if ($input->getInputType() === Input::INPUT_DATA)
{
$request->setHeader('Content-Type', "application/x-www-form-urlencoded");
}
$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-broken-content-length' 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;
}
[$junk, $stupidAmazonDefinedContentLength] = explode(":", $line);
if (strpos($stupidAmazonDefinedContentLength, ',') !== false)
{
if (!isset($requestHeaders['workaround-broken-content-length']))
{
$requestHeaders['workaround-broken-content-length'] = true;
// 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, string $bucket, string $uri): void
{
$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(): Configuration
{
return $this->configuration;
}
}

View file

@ -0,0 +1,19 @@
<?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 RuntimeException;
class CannotDeleteFile extends RuntimeException
{
}

View file

@ -0,0 +1,19 @@
<?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 RuntimeException;
class CannotGetBucket extends RuntimeException
{
}

View file

@ -0,0 +1,19 @@
<?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 RuntimeException;
class CannotGetFile extends RuntimeException
{
}

View file

@ -0,0 +1,19 @@
<?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 RuntimeException;
class CannotListBuckets extends RuntimeException
{
}

View file

@ -0,0 +1,27 @@
<?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;
use RuntimeException;
class CannotOpenFileForRead extends RuntimeException
{
public function __construct(string $file = "", int $code = 0, Exception $previous = null)
{
$message = "Cannot open $file for reading";
parent::__construct($message, $code, $previous);
}
}

View file

@ -0,0 +1,27 @@
<?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;
use RuntimeException;
class CannotOpenFileForWrite extends RuntimeException
{
public function __construct(string $file = "", int $code = 0, Exception $previous = null)
{
$message = "Cannot open $file for writing";
parent::__construct($message, $code, $previous);
}
}

View file

@ -0,0 +1,19 @@
<?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 RuntimeException;
class CannotPutFile extends RuntimeException
{
}

View file

@ -0,0 +1,23 @@
<?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 RuntimeException;
/**
* Configuration error
*/
abstract class ConfigurationError extends RuntimeException
{
}

View file

@ -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(string $message = "", int $code = 0, Exception $previous = null)
{
if (empty($message))
{
$message = 'The Amazon S3 Access Key provided is invalid';
}
parent::__construct($message, $code, $previous);
}
}

View file

@ -0,0 +1,33 @@
<?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;
use RuntimeException;
/**
* Invalid response body type
*/
class InvalidBody extends RuntimeException
{
public function __construct(string $message = "", int $code = 0, Exception $previous = null)
{
if (empty($message))
{
$message = 'Invalid response body type';
}
parent::__construct($message, $code, $previous);
}
}

View file

@ -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(string $message = "", int $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);
}
}

View file

@ -0,0 +1,30 @@
<?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;
use InvalidArgumentException;
class InvalidFilePointer extends InvalidArgumentException
{
public function __construct(string $message = "", int $code = 0, Exception $previous = null)
{
if (empty($message))
{
$message = 'The specified file pointer is not a valid stream resource';
}
parent::__construct($message, $code, $previous);
}
}

View file

@ -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(string $message = "", int $code = 0, Exception $previous = null)
{
if (empty($message))
{
$message = 'The Amazon S3 region provided is invalid.';
}
parent::__construct($message, $code, $previous);
}
}

View file

@ -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(string $message = "", int $code = 0, Exception $previous = null)
{
if (empty($message))
{
$message = 'The Amazon S3 Secret Key provided is invalid';
}
parent::__construct($message, $code, $previous);
}
}

View file

@ -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(string $message = "", int $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);
}
}

View file

@ -0,0 +1,22 @@
<?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 LogicException;
/**
* Invalid magic property name
*/
class PropertyNotFound extends LogicException
{
}

View file

@ -0,0 +1,734 @@
<?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
{
/**
* Input type: resource
*/
const INPUT_RESOURCE = 1;
/**
* Input type: file
*/
const INPUT_FILE = 2;
/**
* Input type: raw data
*/
const INPUT_DATA = 3;
/**
* 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|null
*/
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|null
*/
private $md5sum = null;
/**
* SHA-256 sum of the data to upload, as lowercase hex string.
*
* @var string|null
*/
private $sha256 = null;
/**
* The Upload Session ID used for multipart uploads
*
* @var string|null
*/
private $UploadID = null;
/**
* The part number used in multipart uploads
*
* @var int|null
*/
private $PartNumber = null;
/**
* The list of ETags used when finalising a multipart upload
*
* @var string[]
*/
private $etags = [];
/**
* 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 null|string $md5sum The MD5 sum. null to auto calculate, empty string to never calculate.
* @param null|string $sha256sum The SHA256 sum. null to auto calculate.
*
* @return Input
*/
public static function createFromFile(string $file, ?string $md5sum = null, ?string $sha256sum = null): self
{
$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 calculated 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 null|string $md5sum The MD5 sum. null to auto calculate, empty string to never calculate.
* @param null|string $sha256sum The SHA256 sum. null to auto calculate.
*
* @return Input
*/
public static function createFromResource(&$resource, int $contentLength, ?string $md5sum = null, ?string $sha256sum = null): self
{
$input = new Input();
$input->setFp($resource);
$input->setSize($contentLength);
$input->setMd5sum($md5sum);
$input->setSha256($sha256sum);
return $input;
}
/**
* 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 null|string $md5sum The MD5 sum. null to auto calculate, empty string to never calculate.
* @param null|string $sha256sum The SHA256 sum. null to auto calculate.
*
* @return Input
*/
public static function createFromData(string &$data, ?string $md5sum = null, ?string $sha256sum = null): self
{
$input = new Input();
$input->setData($data);
$input->setMd5sum($md5sum);
$input->setSha256($sha256sum);
return $input;
}
/**
* Destructor.
*/
function __destruct()
{
if (is_resource($this->fp))
{
@fclose($this->fp);
}
}
/**
* Returns the input type (resource, file or data)
*
* @return int
*/
public function getInputType(): int
{
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): void
{
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(): ?string
{
if (empty($this->file))
{
return null;
}
return $this->file;
}
/**
* Set the absolute path to the input file
*
* @param string $file
*/
public function setFile(string $file): void
{
$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|null
*/
public function getData(): ?string
{
if (empty($this->data) && ($this->getInputType() != self::INPUT_DATA))
{
return null;
}
return $this->data;
}
/**
* Set the raw input data
*
* @param string $data
*/
public function setData(string $data): void
{
$this->data = $data;
if (is_resource($this->fp))
{
@fclose($this->fp);
}
$this->file = null;
$this->fp = null;
}
/**
* Return a reference to the raw input data
*
* @return string|null
*/
public function &getDataReference(): ?string
{
if (empty($this->data) && ($this->getInputType() != self::INPUT_DATA))
{
$this->data = null;
}
return $this->data;
}
/**
* 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(string &$data): void
{
$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(): int
{
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(int $size)
{
$this->size = $size;
}
/**
* Get the MIME type of the data
*
* @return string|null
*/
public function getType(): ?string
{
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|null $type
*/
public function setType(?string $type)
{
$this->type = $type;
}
/**
* Get the MD5 sum of the content
*
* @return null|string
*/
public function getMd5sum(): ?string
{
if ($this->md5sum === '')
{
return null;
}
if (is_null($this->md5sum))
{
$this->md5sum = $this->calculateMd5();
}
return $this->md5sum;
}
/**
* Set the MD5 sum of the content as a base64 encoded string of the raw MD5 binary value.
*
* WARNING: Do not set a binary MD5 sum or a hex-encoded MD5 sum, it will result in an invalid signature error!
*
* Set to null to automatically calculate it from the raw data. Set to an empty string to force it to never be
* calculated and no value for it set either.
*
* @param string|null $md5sum
*/
public function setMd5sum(?string $md5sum): void
{
$this->md5sum = $md5sum;
}
/**
* Get the SHA-256 hash of the content
*
* @return string
*/
public function getSha256(): string
{
if (empty($this->sha256))
{
$this->sha256 = $this->calculateSha256();
}
return $this->sha256;
}
/**
* Set the SHA-256 sum of the content. It must be a lowercase hexadecimal encoded string.
*
* Set to null to automatically calculate it from the raw data.
*
* @param string|null $sha256
*/
public function setSha256(?string $sha256): void
{
$this->sha256 = strtolower($sha256);
}
/**
* Get the Upload Session ID for multipart uploads
*
* @return string|null
*/
public function getUploadID(): ?string
{
return $this->UploadID;
}
/**
* Set the Upload Session ID for multipart uploads
*
* @param string|null $UploadID
*/
public function setUploadID(?string $UploadID): void
{
$this->UploadID = $UploadID;
}
/**
* Get the part number for multipart uploads.
*
* Returns null if the part number has not been set yet.
*
* @return int|null
*/
public function getPartNumber(): ?int
{
return $this->PartNumber;
}
/**
* Set the part number for multipart uploads
*
* @param int $PartNumber
*/
public function setPartNumber(int $PartNumber): void
{
// Clamp the part number to integers greater than zero.
$this->PartNumber = max(1, (int) $PartNumber);
}
/**
* Get the list of ETags for multipart uploads
*
* @return string[]
*/
public function getEtags(): array
{
return $this->etags;
}
/**
* Set the list of ETags for multipart uploads
*
* @param string[] $etags
*/
public function setEtags(array $etags): void
{
$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(): int
{
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);
$filesize = @filesize($this->file);
return ($filesize === false) ? 0 : $filesize;
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(string $file): string
{
$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 = [
'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(): string
{
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;
}
return '';
}
/**
* Calcualte the SHA256 data of the input data
*
* @return string Lowercase hex representation of the SHA-256 sum
*/
private function calculateSha256(): string
{
$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;
}
return '';
}
}

View file

@ -0,0 +1,761 @@
<?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 = [];
/**
* Amazon-specific headers to pass to the request
*
* @var array
*/
private $amzHeaders = [];
/**
* Regular HTTP headers to send in the request
*
* @var array
*/
private $headers = [
'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 resource|null
*/
private $fp = null;
/**
* 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(string $verb, string $bucket, string $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|null
*/
public function getInput(): ?Input
{
return $this->input;
}
/**
* Set the input object
*
* @param Input $input
*
* @return void
*/
public function setInput(Input $input): void
{
$this->input = $input;
}
/**
* Set a request parameter
*
* @param string $key The parameter name
* @param string|null $value The parameter value
*
* @return void
*/
public function setParameter(string $key, ?string $value): void
{
$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(string $key, string $value): void
{
$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(string $key, string $value): void
{
$this->amzHeaders[$key] = $value;
}
/**
* Get the HTTP verb of this request
*
* @return string
*/
public function getVerb(): string
{
return $this->verb;
}
/**
* Get the S3 bucket's name
*
* @return string
*/
public function getBucket(): string
{
return $this->bucket;
}
/**
* Get the absolute URI of the resource we're accessing
*
* @return string
*/
public function getResource(): string
{
return $this->resource;
}
/**
* Get the parameters array
*
* @return array
*/
public function getParameters(): array
{
return $this->parameters;
}
/**
* Get the Amazon headers array
*
* @return array
*/
public function getAmzHeaders(): array
{
return $this->amzHeaders;
}
/**
* Get the other headers array
*
* @return array
*/
public function getHeaders(): array
{
return $this->headers;
}
/**
* Get a reference to the Amazon configuration object
*
* @return Configuration
*/
public function getConfiguration(): Configuration
{
return $this->configuration;
}
/**
* Get the file pointer resource (for PUT and POST requests)
*
* @return resource|null
*/
public function &getFp()
{
return $this->fp;
}
/**
* Set the data resource as a file pointer
*
* @param resource $fp
*/
public function setFp($fp): void
{
$this->fp = $fp;
}
/**
* Get the certificate authority location
*
* @return string|null
*/
public function getCaCertLocation(): ?string
{
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(?string $caCertLocation): void
{
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
* private or otherwise inaccessible files from S3.
*
* @param int|null $lifetime Lifetime in seconds
* @param bool $https Use HTTPS ($hostBucket should be false for SSL verification)?
*
* @return string The authenticated URL, complete with signature
*/
public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string
{
$this->processParametersIntoResource();
$signer = Signature::getSignatureObject($this, $this->configuration->getSignatureMethod());
return $signer->getAuthenticatedURL($lifetime, $https);
}
/**
* Get the S3 response
*
* @return Response
*/
public function getResponse(): Response
{
$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'], '.') > 4;
$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 = [];
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, [$this, '__responseWriteCallback']);
curl_setopt($curl, CURLOPT_HEADERFUNCTION, [$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, string $data): int
{
if (in_array($this->response->code, [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, string $data): int
{
if (($strlen = strlen($data)) <= 2)
{
return $strlen;
}
if (substr($data, 0, 4) == 'HTTP')
{
$this->response->code = (int) substr($data, 9, 3);
return $strlen;
}
[$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(): void
{
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, string $bucket): string
{
// 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 https://docs.aws.amazon.com/general/latest/gr/s3.html#s3_region
*
* This means changing the endpoint to s3.REGION.amazonaws.com with the following exceptions:
* For China: s3.REGION.amazonaws.com.cn
*
* v4 signing does NOT support non-Amazon endpoints.
*/
// Most endpoints: s3-REGION.amazonaws.com
$regionalEndpoint = $region . '.amazonaws.com';
// Exception: China
if (substr($region, 0, 3) == 'cn-')
{
// Chinese endpoint, e.g.: s3.cn-north-1.amazonaws.com.cn
$regionalEndpoint = $regionalEndpoint . '.cn';
}
// If dual-stack URLs are enabled then prepend the endpoint
if ($configuration->getDualstackUrl())
{
$endpoint = 's3.dualstack.' . $regionalEndpoint;
}
else
{
$endpoint = 's3.' . $regionalEndpoint;
}
// Legacy path style access: return just the endpoint
if ($configuration->getUseLegacyPathStyle())
{
return $endpoint;
}
// Recommended virtual hosting access: bucket, dot, endpoint.
return $bucket . '.' . $endpoint;
}
}

View file

@ -0,0 +1,345 @@
<?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;
use SimpleXMLElement;
// Protection against direct access
defined('AKEEBAENGINE') or die();
/**
* Amazon S3 API response object
*
* @property Error $error Response error object
* @property string|SimpleXMLElement|null $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 string|SimpleXMLElement|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 = [];
/**
* Response constructor.
*/
public function __construct()
{
$this->error = new Error();
}
/**
* Is this an error response?
*
* @return bool
*/
public function isError(): bool
{
return is_null($this->error) || $this->error->isError();
}
/**
* Does this response have a body?
*
* @return bool
*/
public function hasBody(): bool
{
return !empty($this->body);
}
/**
* Get the response error object
*
* @return Error
*/
public function getError(): Error
{
return $this->error;
}
/**
* Set the response error object
*
* @param Error $error
*/
public function setError(Error $error): void
{
$this->error = $error;
}
/**
* Get the response body
*
* If there is no body set up you get NULL.
*
* If the body is binary data (e.g. downloading a file) or other non-XML data you get a string.
*
* If the body was an XML string the standard Amazon S3 REST API response type you get a SimpleXMLElement
* object.
*
* @return string|SimpleXMLElement|null
*/
public function getBody()
{
return $this->body;
}
/**
* Set the response body. If it's a string we'll try to parse it as XML.
*
* @param string|SimpleXMLElement|null $body
*/
public function setBody($body): void
{
$this->body = null;
if (empty($body))
{
return;
}
$this->body = $body;
$this->finaliseBody();
}
public function resetBody(): void
{
$this->body = null;
}
public function addToBody(string $data): void
{
if (empty($this->body))
{
$this->body = '';
}
$this->body .= $data;
}
public function finaliseBody(): void
{
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(): int
{
return $this->code;
}
/**
* Sets the status code of the response
*
* @param int $code
*/
public function setCode(int $code): void
{
$this->code = $code;
}
/**
* Get the response headers
*
* @return array
*/
public function getHeaders(): array
{
return $this->headers;
}
/**
* Set the response headers
*
* @param array $headers
*/
public function setHeaders(array $headers): void
{
$this->headers = $headers;
}
/**
* Set a single header
*
* @param string $name The header name
* @param string $value The header value
*
* @return void
*/
public function setHeader(string $name, string $value): void
{
$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(string $name): bool
{
return array_key_exists($name, $this->headers);
}
/**
* Unset a response header
*
* @param string $name The header to unset
*
* @return void
*/
public function unsetHeader(string $name): void
{
if ($this->hasHeader($name))
{
unset ($this->headers[$name]);
}
}
/**
* Magic getter for the protected properties
*
* @param string $name
*
* @return mixed
*/
public function __get(string $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(string $name, $value): void
{
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(): void
{
if (!in_array($this->code, [200, 204]) &&
isset($this->body->Code, $this->body->Message)
)
{
$this->error = new Error(
$this->code,
(string) $this->body->Message
);
if (isset($this->body->Resource))
{
$this->error->setResource((string) $this->body->Resource);
}
}
}
}

View file

@ -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 void
*/
function __construct($code = 0, $message = '', $resource = '')
{
$this->setCode($code);
$this->setMessage($message);
$this->setResource($resource);
}
/**
* Get the error code
*
* @return int
*/
public function getCode(): int
{
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(int $code): void
{
if ($code <= 0)
{
$code = 0;
$this->setMessage('');
$this->setResource('');
}
$this->code = $code;
}
/**
* Get the error message
*
* @return string
*/
public function getMessage(): string
{
return $this->message;
}
/**
* Set the error message
*
* @param string $message The error message to set
*
* @return void
*/
public function setMessage(string $message): void
{
$this->message = $message;
}
/**
* Get the URI of the resource throwing the error
*
* @return string
*/
public function getResource(): string
{
return $this->resource;
}
/**
* Set the URI of the resource throwing the error
*
* @param string $resource
*
* @return void
*/
public function setResource(string $resource): void
{
$this->resource = $resource;
}
/**
* Do we actually have an error?
*
* @return bool
*/
public function isError(): bool
{
return ($this->code > 0) || !empty($this->message);
}
}

View file

@ -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;
}
/**
* 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, string $method = 'v2'): self
{
$className = '\\Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Signature\\' . ucfirst($method);
return new $className($request);
}
/**
* Returns the authorization header for the request
*
* @return string
*/
abstract public function getAuthorizationHeader(): string;
/**
* 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(array &$headers, array &$amzHeaders): void;
/**
* 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|null $lifetime Lifetime in seconds. NULL for default lifetime.
* @param bool $https Use HTTPS ($hostBucket should be false for SSL verification)?
*
* @return string The authenticated URL, complete with signature
*/
abstract public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string;
}

View file

@ -0,0 +1,209 @@
<?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(array &$headers, array &$amzHeaders): void
{
// 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|null $lifetime Lifetime in seconds. NULL for default lifetime.
* @param bool $https Use HTTPS ($hostBucket should be false for SSL verification)?
*
* @return string The presigned URL
*/
public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string
{
// 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(), [
'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(): string
{
$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 = [];
$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 $stringToSign String to sign
*
* @return string
*/
private function amazonV2Hash(string $stringToSign): string
{
$secret = $this->request->getConfiguration()->getSecret();
if (extension_loaded('hash'))
{
$raw = hash_hmac('sha1', $stringToSign, $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))) . $stringToSign
)
)
)
);
return base64_encode($raw);
}
}

View file

@ -0,0 +1,385 @@
<?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;
use DateTime;
/**
* 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(array &$headers, array &$amzHeaders): void
{
// 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|null $lifetime Lifetime in seconds. NULL for default lifetime.
* @param bool $https Use HTTPS ($hostBucket should be false for SSL verification)?
*
* @return string The presigned URL
*/
public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string
{
// 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(): string
{
$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 = [];
// 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 = [];
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): string
{
$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 $toEncode): string
{
if (empty($toEncode))
{
return '';
}
return str_replace('+', '%20', urlencode($toEncode));
}
/**
* Get the correct hostname for the given AWS region
*
* @param string $region
*
* @return string
*/
private function getPresignedHostnameForRegion(string $region): string
{
$endpoint = 's3.' . $region . '.amazonaws.com';
$dualstackEnabled = $this->request->getConfiguration()->getDualstackUrl();
// If dual-stack URLs are enabled then prepend the endpoint
if ($dualstackEnabled)
{
$endpoint = 's3.dualstack.' . $region . '.amazonaws.com';
}
if ($region == 'cn-north-1')
{
return $endpoint . '.cn';
}
return $endpoint;
}
}

View file

@ -0,0 +1,98 @@
<?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;
/**
* Amazon S3 Storage Classes
*
* When you want to override the default storage class of the bucket pass
* array('X-Amz-Storage-Class' => StorageClass::STANDARD)
* in the $headers array of Connector::putObject().
*
* Alternatively, run the $headers array through setStorageClass(), e.g.
* $headers = array(); // You can put your stuff here
* StorageClass::setStorageClass($headers, StorageClass::STANDARD);
* $connector->putObject($myInput, 'bucketname', 'path/to/object.dat', Acl::PRIVATE, $headers)
*
* @see https://aws.amazon.com/s3/storage-classes/
*/
class StorageClass
{
/**
* Amazon S3 Standard (S3 Standard)
*/
const STANDARD = 'STANDARD';
/**
* Reduced redundancy storage
*
* Not recommended anymore. Use INTELLIGENT_TIERING instead.
*/
const REDUCED_REDUNDANCY = 'REDUCED_REDUNDANCY';
/**
* Amazon S3 Intelligent-Tiering (S3 Intelligent-Tiering)
*/
const INTELLIGENT_TIERING = 'INTELLIGENT_TIERING';
/**
* Amazon S3 Standard-Infrequent Access (S3 Standard-IA)
*/
const STANDARD_IA = 'STANDARD_IA';
/**
* Amazon S3 One Zone-Infrequent Access (S3 One Zone-IA)
*/
const ONEZONE_IA = 'ONEZONE_IA';
/**
* Amazon S3 Glacier (S3 Glacier)
*/
const GLACIER = 'GLACIER';
/**
* Amazon S3 Glacier Deep Archive (S3 Glacier Deep Archive)
*/
const DEEP_ARCHIVE = 'DEEP_ARCHIVE';
/**
* Manipulate the $headers array, setting the X-Amz-Storage-Class header for the requested storage class.
*
* This method will automatically remove any previously set X-Amz-Storage-Class header, case-insensitive. The reason
* for that is that Amazon headers **are** case-insensitive and you could easily end up having two separate headers
* with competing storage classes. This would mess up the signature and your request would promptly fail.
*
* @param array $headers
* @param string $storageClass
*
* @return void
*/
public static function setStorageClass(array &$headers, string $storageClass): void
{
// Remove all previously set X-Amz-Storage-Class headers (case-insensitive)
$killList = [];
foreach ($headers as $key => $value)
{
if (strtolower($key) === 'x-amz-storage-class')
{
$killList[] = $key;
}
}
foreach ($killList as $key)
{
unset($headers[$key]);
}
// Add the new X-Amz-Storage-Class header
$headers['X-Amz-Storage-Class'] = $storageClass;
}
}