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

23
s3_storage/composer.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "friendica-addons/s3_storage",
"description": "Adds the possibility to use S3 as a selectable storage backend",
"type": "friendica-addon",
"authors": [
{
"name": "Philipp Holzer",
"email": "admin@philipp.info",
"homepage": "https://blog.philipp.info",
"role": "Developer"
}
],
"require": {
"php": ">=7.0",
"akeeba/s3": "^2.0"
},
"license": "3-clause BSD license",
"config": {
"optimize-autoloader": true,
"autoloader-suffix": "S3StorageAddon",
"preferred-install": "dist"
}
}

65
s3_storage/composer.lock generated Normal file
View file

@ -0,0 +1,65 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "380e2e4853de027e64448857e2e9fead",
"packages": [
{
"name": "akeeba/s3",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/akeeba/s3.git",
"reference": "01520dae1f736555e08efda0ddc1044701bd340a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/akeeba/s3/zipball/01520dae1f736555e08efda0ddc1044701bd340a",
"reference": "01520dae1f736555e08efda0ddc1044701bd340a",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-simplexml": "*",
"php": ">=7.1.0 <8.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Akeeba\\Engine\\Postproc\\Connector\\S3v4\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0+"
],
"authors": [
{
"name": "Nicholas K. Dionysopoulos",
"email": "nicholas_NO_SPAM_PLEASE@akeeba.com",
"homepage": "http://www.dionysopoulos.me",
"role": "Lead Developer"
}
],
"description": "A compact, dependency-less Amazon S3 API client implementing the most commonly used features",
"homepage": "https://github.com/akeeba/s3",
"keywords": [
"s3"
],
"time": "2020-11-30T14:03:55+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=7.0"
},
"platform-dev": [],
"plugin-api-version": "1.1.0"
}

42
s3_storage/s3_storage.php Normal file
View file

@ -0,0 +1,42 @@
<?php
/*
* Name: S3 Storage
* Description: Adds the possibility to use Amazon S3 as a selectable storage backend
* Version: 1.0
* Author: Philipp Holzer
*/
use Friendica\Addon\s3_storage\src\S3Client;
use Friendica\Addon\s3_storage\src\S3Config;
use Friendica\App;
use Friendica\Core\Hook;
use Friendica\DI;
require_once __DIR__ . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';
function s3_storage_install($a)
{
Hook::register('storage_instance' , __FILE__, 's3_storage_instance');
Hook::register('storage_config' , __FILE__, 's3_storage_config');
DI::storageManager()->register(S3Client::class);
}
function s3_storage_uninstall()
{
DI::storageManager()->unregister(S3Client::class);
}
function s3_storage_instance(App $a, array &$data)
{
if ($data['name'] == S3Client::getName()) {
$config = new S3Config(DI::l10n(), DI::config());
$data['storage'] = new S3Client($config->getConfig(), $config->getBucket());
}
}
function s3_storage_config(App $a, array &$data)
{
if ($data['name'] == S3Client::getName()) {
$data['storage_config'] = new S3Config(DI::l10n(), DI::config());
}
}

101
s3_storage/src/S3Client.php Normal file
View file

@ -0,0 +1,101 @@
<?php
namespace Friendica\Addon\s3_storage\src;
defined('AKEEBAENGINE') or define('AKEEBAENGINE', 1);
use Akeeba\Engine\Postproc\Connector\S3v4\Configuration;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Akeeba\Engine\Postproc\Connector\S3v4\Exception\CannotDeleteFile;
use Akeeba\Engine\Postproc\Connector\S3v4\Input;
use Friendica\Core\Storage\Capability\ICanWriteToStorage;
use Friendica\Core\Storage\Exception\StorageException;
use Friendica\Util\Strings;
/**
* A WebDav Backend Storage class
*/
class S3Client implements ICanWriteToStorage
{
const NAME = 'S3';
/** @var Connector */
protected $connector;
/** @var string The name of the bucket used for the backend */
protected $bucket;
public function __construct(Configuration $config, string $bucket)
{
$this->connector = new Connector($config);
$this->bucket = $bucket;
}
/**
* Split data ref and return file path
*
* @param string $reference Data reference
*
* @return string
*/
private function pathForRef(string $reference): string
{
$fold1 = substr($reference, 0, 2);
$fold2 = substr($reference, 2, 2);
$file = substr($reference, 4);
return implode('/', [$fold1, $fold2, $file]);
}
/** {@inheritDoc} */
public function __toString(): string
{
return self::getName();
}
/** {@inheritDoc} */
public static function getName(): string
{
return self::NAME;
}
/** {@inheritDoc} */
public function get(string $reference): string
{
try {
return $this->connector->getObject($this->bucket, $this->pathForRef($reference), false);
} catch (\RuntimeException $exception) {
throw new StorageException(sprintf('Cannot get reference %s', $reference), $exception->getCode(), $exception);
}
}
/** {@inheritDoc} */
public function put(string $data, string $reference = ""): string
{
if ($reference === '') {
try {
$reference = Strings::getRandomHex();
} catch (\Exception $exception) {
throw new StorageException('S3 storage failed to generate a random hex', $exception->getCode(), $exception);
}
}
try {
$input = Input::createFromData($data);
$this->connector->putObject($input, $this->bucket, $this->pathForRef($reference));
return $reference;
} catch (\Exception $exception) {
throw new StorageException(sprintf('Cannot put data for reference %s', $reference), $exception->getCode(), $exception);
}
}
/** {@inheritDoc} */
public function delete(string $reference)
{
try {
$this->connector->deleteObject($this->bucket, $this->pathForRef($reference));
} catch (CannotDeleteFile $exception) {
throw new StorageException(sprintf('Cannot delete reference %s', $reference), $exception->getCode(), $exception);
}
}
}

254
s3_storage/src/S3Config.php Normal file
View file

@ -0,0 +1,254 @@
<?php
namespace Friendica\Addon\s3_storage\src;
defined('AKEEBAENGINE') or define('AKEEBAENGINE', 1);
use Akeeba\Engine\Postproc\Connector\S3v4\Configuration;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Friendica\Core\Config\Capability\IManageConfigValues;
use Friendica\Core\L10n;
use Friendica\Core\Storage\Capability\ICanConfigureStorage;
use ParagonIE\HiddenString\HiddenString;
/**
* The WebDav Backend Storage configuration class
*/
class S3Config implements ICanConfigureStorage
{
const NAME = 'S3Config';
const DEFAULT_REGION = 'us-east-1';
const DEFAULT_ENDPOINT = 's3.amazonaws.com';
const DEFAULT_SIGMETHOD = 'v2';
const DEFAULT_BUCKET = 'friendica';
/** @var L10n */
private $l10n;
/** @var IManageConfigValues */
private $config;
/** @var string */
private $endpoint;
/** @var HiddenString */
private $accessKey;
/** @var HiddenString */
private $secretKey;
/** @var string */
private $signatureMethod;
/** @var ?string */
private $bucket;
/** @var ?string */
private $region;
/** @var bool */
private $legacy;
/** @var bool */
private $dualStack;
public function __construct(L10n $l10n, IManageConfigValues $config)
{
$this->l10n = $l10n;
$this->config = $config;
$this->accessKey = new HiddenString($this->config->get('s3', 'access_key', ''));
$this->secretKey = new HiddenString($this->config->get('s3', 'secret_key', ''));
$this->signatureMethod = $this->config->get('s3', 'signature_method', self::DEFAULT_SIGMETHOD);
$this->bucket = $this->config->get('s3', 'bucket', self::DEFAULT_BUCKET);
$this->legacy = !empty($this->config->get('s3', 'legacy'));
$this->dualStack = !empty($this->config->get('s3', 'dual_stack'));
$this->region = $this->config->get('s3', 'region');
$this->endpoint = $this->config->get('s3', 'endpoint');
}
/**
* Returns the whole configuration as a Akeeba compatible configuration instance
*
* @return Configuration
*/
public function getConfig(): Configuration
{
$config = new Configuration($this->accessKey, $this->secretKey);
$config->setUseLegacyPathStyle($this->legacy ?? false);
$config->setUseDualstackUrl($this->dualStack ?? false);
if (!empty($this->region)) {
$config->setRegion($this->region);
}
if (!empty($this->endpoint)) {
$config->setEndpoint($this->endpoint);
}
if (!empty($this->signatureMethod) && empty($this->endpoint)) {
$config->setSignatureMethod($this->signatureMethod);
}
return $config;
}
public function getBucket(): string
{
return $this->bucket;
}
/**
* @inheritDoc
*/
public function getOptions(): array
{
$sigMethods = [
'v2' => 'v2',
'v4' => 'v4',
];
return [
'access_key' => [
'password',
$this->l10n->t('Access Key'),
$this->accessKey,
$this->l10n->t('Set the Access Key of the S3 storage'),
true,
],
'secret_key' => [
'password',
$this->l10n->t('Secret Key'),
$this->secretKey,
$this->l10n->t('Set the Secret Key of the S3 storage'),
true,
],
'bucket' => [
'input',
$this->l10n->t('Bucket'),
$this->bucket,
$this->l10n->t('The S3 Bucket (name), you want to use with Friendica'),
true,
],
'signature_method' => [
'select',
$this->l10n->t('Signature Method'),
$this->signatureMethod,
$this->l10n->t('Set the signature method to use (BEWARE: v4 will be automatically set to v2 in case the endpoint isn\'t amazon)'),
$sigMethods,
],
'endpoint' => [
'input',
$this->l10n->t("Amazon S3 compatible endpoint (leave empty for '%s')", self::DEFAULT_ENDPOINT),
$this->endpoint,
$this->l10n->t('Set the S3 endpoint. Do NOT use a protocol (You can use a custom endpoint with v2 signatures to access third party services which offer S3 compatibility, e.g. OwnCloud, Google Storage etc.)'),
],
'region' => [
'input',
$this->l10n->t("AWS region (leave empty for '%s')", self::DEFAULT_REGION),
$this->region,
$this->l10n->t('The AWS region is mandatory for v4 signatures'),
],
'dualstack_url' => [
'checkbox',
$this->l10n->t('Use the dualstack URL (which will ship traffic over ipv6 in most cases)'),
$this->dualStack,
$this->l10n->t('For more information on these endpoints please read https://docs.aws.amazon.com/AmazonS3/latest/dev/dual-stack-endpoints.html'),
],
'legacy' => [
'checkbox',
$this->l10n->t('Use legacy, path-style access to the bucket'),
$this->legacy,
$this->l10n->t('When it\'s turned off (default) we use virtual hosting stylepaths which are RECOMMENDED BY AMAZON per http://docs.aws.amazon.com/AmazonS3/latest/API/APIRest.html'),
],
];
}
/**
* @inheritDoc
*/
public function saveOptions(array $data): array
{
$feedback = [];
if (empty($data['access_key']) || empty($data['secret_key']) || empty($data['bucket'])) {
return [
'access_key' => $this->l10n->t('Invalid input')
];
}
$s3Config = new Configuration(
$data['access_key'],
$data['secret_key']
);
$bucket = $data['bucket'];
if (!empty($data['endpoint'])) {
try {
$s3Config->setEndpoint($data['endpoint']);
} catch (\Exception $exception) {
$feedback['endpoint'] = $exception->getMessage();
}
}
if (!empty($data['region'])) {
try {
$s3Config->setRegion($data['region']);
} catch (\Exception $exception) {
$feedback['region'] = $exception->getMessage();
}
}
try {
$s3Config->setUseLegacyPathStyle(!empty($data['legacy']));
} catch (\Exception $exception) {
$feedback['legacy'] = $exception->getMessage();
}
try {
$s3Config->setUseDualstackUrl(!empty($data['dualstack_url']));
} catch (\Exception $exception) {
$feedback['dualstack_url'] = $exception->getMessage();
}
try {
$s3Config->setSignatureMethod($data['signature_method'] ?? self::DEFAULT_SIGMETHOD);
} catch (\Exception $exception) {
$feedback['signature_method'] = $this->l10n->t("error '%s', message '%s'", $exception->getCode(), $exception->getMessage());
}
try {
$connector = new Connector($s3Config);
$buckets = $connector->listBuckets();
if (!in_array($bucket, $buckets)) {
return [
'bucket' => $this->l10n->t('Bucket %s cannot be not found, possible buckets: %s', $bucket, implode(', ', $buckets))
];
}
$connector->getBucket($bucket);
} catch (\Exception $exception) {
return [
'bucket' => $exception->getMessage()
];
}
$this->config->set('s3', 'access_key', ($this->accessKey = new HiddenString($data['access_key']))->getString());
$this->config->set('s3', 'secret_key', ($this->secretKey = new HiddenString($data['secret_key']))->getString());
$this->config->set('s3', 'bucket', ($this->bucket = $bucket));
$this->config->set('s3', 'legacy', $s3Config->getUseLegacyPathStyle());
$this->config->set('s3', 'dual_stack', $s3Config->getUseLegacyPathStyle());
$this->config->set('s3','signature_method', $s3Config->getSignatureMethod());
if (!empty($data['endpoint'])) {
$this->config->set('s3', 'endpoint', $s3Config->getEndpoint());
} else {
$this->config->delete('s3', 'endpoint');
}
if (!empty($data['region'])) {
$this->config->set('s3', 'region', $s3Config->getRegion());
} else {
$this->config->delete('s3', 'region');
}
return $feedback;
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace Friendica\Addon\webdav_storage\tests;
use Friendica\Addon\webdav_storage\src\WebDav;
use Friendica\DI;
use Friendica\Network\HTTPClient\Factory\HTTPClientFactory;
use Friendica\Test\src\Core\Storage\StorageTest;
use Friendica\Core\Logger\Type\VoidLogger;
/// @todo remove when constant is moved to a class constant
/// Necessary for DB_UPDATE_VERSION constant in case of direct calls, where dbstructure isn't included during the calling process
require_once __DIR__ . '/../../../static/dbstructure.config.php';
class WebDavTest extends StorageTest
{
public function dataMultiStatus()
{
return [
'nextcloud' => [
'xml' => <<<EOF
<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/admin/Friendica_test/97/18/</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>Mon, 30 Aug 2021 12:58:54 GMT</d:getlastmodified>
<d:resourcetype>
<d:collection/>
</d:resourcetype>
<d:quota-used-bytes>45017</d:quota-used-bytes>
<d:quota-available-bytes>59180834349</d:quota-available-bytes>
<d:getetag>&quot;612cd60ec9fd5&quot;</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>
/remote.php/dav/files/admin/Friendica_test/97/18/4d9d36f614dc005756bdfb9abbf1d8d24aa9ae842e5d6b5e7eb1dafbe767
</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>Mon, 30 Aug 2021 12:58:54 GMT</d:getlastmodified>
<d:getcontentlength>45017</d:getcontentlength>
<d:resourcetype/>
<d:getetag>&quot;4f7a144092532141d0e6b925e50a896e&quot;</d:getetag>
<d:getcontenttype>application/octet-stream
</d:getcontenttype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:quota-used-bytes/>
<d:quota-available-bytes/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found
</d:status>
</d:propstat>
</d:response>
</d:multistatus>
EOF,
'assertionCount' => 2,
],
'onlyDir' => [
'xml' => <<<EOF
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/admin/Friendica_test/34/cf/</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>Sun, 05 Sep 2021 17:56:05 GMT</d:getlastmodified>
<d:resourcetype>
<d:collection/>
</d:resourcetype>
<d:quota-used-bytes>0</d:quota-used-bytes>
<d:quota-available-bytes>59182800697</d:quota-available-bytes>
<d:getetag>"613504b55db4f"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
EOF,
'assertionCount' => 1,
],
];
}
/**
* @dataProvider dataMultiStatus
*/
public function testMultistatus(string $xml, int $assertionCount)
{
$responseDoc = new \DOMDocument();
$responseDoc->loadXML($xml);
$xpath = new \DOMXPath($responseDoc);
$xpath->registerNamespace('d', 'DAV');
self::assertCount($assertionCount, $xpath->query('//d:multistatus/d:response'));
}
/**
* @inheritDoc
*/
protected function getInstance()
{
/** @var HTTPClientFactory $factory */
$factory = DI::getDice()->create(HTTPClientFactory::class);
return new WebDav(getenv('WEBDAV_URL'), [
getenv('WEBDAV_USERNAME'),
getenv('WEBDAV_PASSWORD'),
'basic',
], $factory->createClient(), new VoidLogger());
}
}

View file

@ -0,0 +1,5 @@
/.idea/
/000/
/minitest/config.php
/minitest/tmp
/vendor/

295
s3_storage/vendor/akeeba/s3/README.md vendored Normal file
View file

@ -0,0 +1,295 @@
# Akeeba Amazon S3 Connector
A compact, dependency-less Amazon S3 API client implementing the most commonly used features
## Why reinvent the wheel
After having a lot of impossible to debug problems with Amazon's Guzzle-based AWS SDK we decided to roll our own connector for Amazon S3. This is by no means a complete implementation, just a small subset of S3's features which are required by our software. The design goals are simplicity, no external dependencies and low memory footprint.
This code was originally based on [S3.php written by Donovan Schonknecht](http://undesigned.org.za/2007/10/22/amazon-s3-php-class) which is available under a BSD-like license. This repository no longer reflects the original author's work and should not be confused with it.
This software is distributed under the GNU General Public License version 3 or, at your option, any later version published by the Free Software Foundation (FSF). In short, it's "GPLv3+".
## Important note about version 2
Akeeba Amazon S3 Connector version 2 has dropped support for PPH 5.3 to 7.0 inclusive. It is only compatible with PHP 7.1 or later, up to and including PHP 8.0.
The most significant change in this version is that all methods use scalar type hints for parameters and return values. This _may_ break existing consumers which relied on implicit type conversion e.g. passing strings containing integer values instead of _actual_ integer values.
## Using the connector
### Get a connector object
```php
$configuration = new \Akeeba\Engine\Postproc\Connector\S3v4\Configuration(
'YourAmazonAccessKey',
'YourAmazonSecretKey'
);
$connector = new \Akeeba\Engine\Postproc\Connector\S3v4\Connector($configuration);
```
If you are running inside an Amazon EC2 instance you can fetch temporary credentials from the instance's metadata
server using the IAM Role attached to the EC2 instance. In this case you need to do this (169.254.169.254 is a fixed
IP hosting the instance's metadata cache service):
```php
$role = file_get_contents('http://169.254.169.254/latest/meta-data/iam/security-credentials/');
$jsonCredentials = file_get_contents('http://169.254.169.254/latest/meta-data/iam/security-credentials/' . $role);
$credentials = json_decode($jsonCredentials, true);
$configuration = new \Akeeba\Engine\Postproc\Connector\S3v4\Configuration(
$credentials['AccessKeyId'],
$credentials['SecretAccessKey'],
'v4',
$yourRegion
);
$configuration->setToken($credentials['Token']);
$connector = new \Akeeba\Engine\Postproc\Connector\S3v4\Connector($configuration);
```
where `$yourRegion` is the AWS region of your bucket, e.g. `us-east-1`. Please note that we are passing the security
token (`$credentials['Token']`) to the Configuration object. This is REQUIRED. The temporary credentials returned by
the metadata service won't work without it.
Also worth noting is that the temporary credentials don't last forever. Check the `$credentials['Expiration']` to see
when they are about to expire. Amazon recommends that you retry fetching new credentials from the metadata service
10 minutes before your cached credentials are set to expire. The metadata service is guaranteed to provision fresh
temporary credentials by that time.
### Listing buckets
```php
$listing = $connector->listBuckets(true);
```
Returns an array like this:
```
array(2) {
'owner' =>
array(2) {
'id' =>
string(64) "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
'name' =>
string(8) "someUserName"
}
'buckets' =>
array(3) {
[0] =>
array(2) {
'name' =>
string(10) "mybucket"
'time' =>
int(1267730711)
}
[1] =>
array(2) {
'name' =>
string(10) "anotherbucket"
'time' =>
int(1269516249)
}
[2] =>
array(2) {
'name' =>
string(11) "differentbucket"
'time' =>
int(1354458048)
}
}
}
```
### Listing bucket contents
```php
$listing = $connector->getBucket('mybucket', 'path/to/list/');
```
If you want to list "subdirectories" you need to do
```php
$listing = $connector->getBucket('mybucket', 'path/to/list/', null, null, '/', true);
```
The last parameter (common prefixes) controls the listing of "subdirectories"
### Uploading (small) files
From a file:
```php
$input = \Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromFile($sourceFile);
$connector->putObject($input, 'mybucket', 'path/to/myfile.txt');
```
From a string:
```php
$input = \Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromData($sourceString);
$connector->putObject($input, 'mybucket', 'path/to/myfile.txt');
```
From a stream resource:
```php
$input = \Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromResource($streamHandle, false);
$connector->putObject($input, 'mybucket', 'path/to/myfile.txt');
```
In all cases the entirety of the file has to be loaded in memory.
### Uploading large file with multipart (chunked) uploads
Files are uploaded in 5Mb chunks.
```php
$input = \Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromFile($sourceFile);
$uploadId = $connector->startMultipart($input, 'mybucket', 'mypath/movie.mov');
$eTags = array();
$eTag = null;
$partNumber = 0;
do
{
// IMPORTANT: You MUST create the input afresh before each uploadMultipart call
$input = \Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromFile($sourceFile);
$input->setUploadID($uploadId);
$input->setPartNumber(++$partNumber);
$eTag = $connector->uploadMultipart($input, 'mybucket', 'mypath/movie.mov');
if (!is_null($eTag))
{
$eTags[] = $eTag;
}
}
while (!is_null($eTag));
// IMPORTANT: You MUST create the input afresh before finalising the multipart upload
$input = \Akeeba\Engine\Postproc\Connector\S3v4\Input::createFromFile($sourceFile);
$input->setUploadID($uploadId);
$input->setEtags($eTags);
$connector->finalizeMultipart($input, 'mybucket', 'mypath/movie.mov');
```
As long as you keep track of the UploadId, PartNumber and ETags you can have each uploadMultipart call in a separate
page load to prevent timeouts.
### Get presigned URLs
Allows browsers to download files directly without exposing your credentials and without going through your server:
```php
$preSignedURL = $connector->getAuthenticatedURL('mybucket', 'path/to/file.jpg', 60);
```
The last parameter controls how many seconds into the future this URL will be valid.
### Download
To a file with absolute path `$targetFile`
```php
$connector->getObject('mybucket', 'path/to/file.jpg', $targetFile);
```
To a string
```php
$content = $connector->getObject('mybucket', 'path/to/file.jpg', false);
```
### Delete an object
```php
$connector->deleteObject('mybucket', 'path/to/file.jpg');
```
## Configuration options
The Configuration option has optional methods which can be used to enable some useful features in the connector.
You need to execute these methods against the Configuration object before passing it to the Connector's constructor. For example:
```php
$configuration = new \Akeeba\Engine\Postproc\Connector\S3v4\Configuration(
'YourAmazonAccessKey',
'YourAmazonSecretKey'
);
// Use v4 signatures and Dualstack URLs
$configuration->setSignatureMethod('v4');
$configuration->setUseDualstackUrl(true);
$connector = new \Akeeba\Engine\Postproc\Connector\S3v4\Connector($configuration);
```
### HTTPS vs plain HTTP
**It is not recommended to use plain HTTP connections to Amazon S3**. If, however, you have no other option you can tell the Configuration object to use plain HTTP URLs:
```php
$configuration->setSSL(false);
```
### Custom endpoint
You can use the Akeeba Amazon S3 Connector library with S3-compatible APIs such as DigitalOcean's Spaces by changing the endpoint URL.
Please note that if the S3-compatible APi uses v4 signatures you need to enter the region-specific endpoint domain name and the region when initializing the object, e.g.:
```php
// DigitalOcean Spaces using v4 signatures
// The access credentials are those used in the example at https://developers.digitalocean.com/documentation/spaces/
$configuration = new \Akeeba\Engine\Postproc\Connector\S3v4\Configuration(
'532SZONTQ6ALKBCU94OU',
'zCkY83KVDXD8u83RouEYPKEm/dhPSPB45XsfnWj8fxQ',
'v4',
'nyc3'
);
$configuration->setEndpoint('nyc3.digitaloceanspaces.com');
$connector = new \Akeeba\Engine\Postproc\Connector\S3v4\Connector($configuration);
```
If your S3-compatible API uses v2 signatures you do not need to specify a region.
```php
// DigitalOcean Spaces using v2 signatures
// The access credentials are those used in the example at https://developers.digitalocean.com/documentation/spaces/
$configuration = new \Akeeba\Engine\Postproc\Connector\S3v4\Configuration(
'532SZONTQ6ALKBCU94OU',
'zCkY83KVDXD8u83RouEYPKEm/dhPSPB45XsfnWj8fxQ',
'v2'
);
$configuration->setEndpoint('nyc3.digitaloceanspaces.com');
$connector = new \Akeeba\Engine\Postproc\Connector\S3v4\Connector($configuration);
```
### Legacy path-style access
The S3 API calls made by this library will use by default the subdomain-style access. That is to say, the endpoint will be prefixed with the name of the bucket. For example, a bucket called `example` in the `eu-west-1` region will be accessed using the endpoint URL `example.s3.eu-west-1.amazonaws.com`.
If you have buckets with characters that are invalid in the context of DNS (most notably dots and uppercase characters) this will fail. You will need to use the legacy path style instead. In this case the endpoint used is the generic region specific one (`s3.eu-west-1.amazonaws.com` in our example above) and the API URL will be prefixed with the bucket name.
You need to do:
```php
$configuration->setUseLegacyPathStyle(true);
```
Caveat: this will not work with v2 signatures if you are using Amazon AWS S3 proper. It will work with the v2 signatures if you are using a custom endpoint, though. In fact, most S3-compatible APIs implementing V2 signatures _expect_ you to use path-style access.
### Dualstack (IPv4 and IPv6) support
Amazon S3 supports dual-stack URLs which resolve to both IPv4 and IPv6 addresses. By default they are _not_ used. If you want to enable this feature you need to do:
```php
$connector->setUseDualstackUrl(true);
```
Caveat: this option only takes effect if you are using Amazon S3 proper. It will _not_ have any effect with custom endpoints.

13
s3_storage/vendor/akeeba/s3/TODO.md vendored Normal file
View file

@ -0,0 +1,13 @@
Need to check:
endpoint in [amazon, custom]
signature in [v2, v4]
path style in [true, false]
upload
download
presigned URL generation
presigned URL access
USING VIRTUAL HOSTING, v4 SIGNATURES
presigned URL must use s3.amazonaws.com i.e. path-style hosting (because who needs logic?)

View file

@ -0,0 +1,28 @@
{
"name": "akeeba/s3",
"type": "library",
"description": "A compact, dependency-less Amazon S3 API client implementing the most commonly used features",
"require": {
"php": ">=7.1.0 <8.1",
"ext-curl": "*",
"ext-simplexml": "*"
},
"keywords": [
"s3"
],
"homepage": "https://github.com/akeeba/s3",
"license": "GPL-3.0+",
"authors": [
{
"name": "Nicholas K. Dionysopoulos",
"email": "nicholas_NO_SPAM_PLEASE@akeeba.com",
"homepage": "http://www.dionysopoulos.me",
"role": "Lead Developer"
}
],
"autoload": {
"psr-4": {
"Akeeba\\Engine\\Postproc\\Connector\\S3v4\\": "src"
}
}
}

19
s3_storage/vendor/akeeba/s3/composer.lock generated vendored Normal file
View file

@ -0,0 +1,19 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"content-hash": "1070071b351d45a80934e854f0725d64",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=5.3.4"
},
"platform-dev": []
}

View file

@ -0,0 +1,242 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use RuntimeException;
abstract class AbstractTest
{
const TEN_KB = 10240;
const HUNDRED_KB = 102400;
const SIX_HUNDRED_KB = 614400;
const ONE_MB = 1048576;
const FIVE_MB = 5242880;
const SIX_MB = 6291456;
const TEN_MB = 10485760;
const ELEVEN_MB = 11534336;
const BLOCK_SIZE = 1048576;
const FILE_HASHING_ALGORITHM = 'sha256';
public static function setup(Connector $s3, array $options): void
{
// Runs before any test
}
public static function teardown(Connector $s3, array $options): void
{
// Runs after all tests are finished
}
/**
* Creates a file with random data and returns its file path.
*
* The random data in the file repeats every $blockSize bytes when $reuseBlock is true.
*
* @param int $size Size in files
*
* @param int $blockSize
* @param bool $reuseBlock
*
* @return string The full path to the temporary file.
*/
protected static function createFile(int $size = AbstractTest::SIX_HUNDRED_KB, int $blockSize = self::BLOCK_SIZE, bool $reuseBlock = true)
{
$tempFilePath = tempnam(self::getTempFolder(), 'as3');
if ($tempFilePath === false)
{
throw new RuntimeException("Cannot create a temporary file.");
}
$fp = @fopen($tempFilePath, 'wb', false);
if ($fp === false)
{
throw new RuntimeException("Cannot write to the temporary file.");
}
$blockSize = self::BLOCK_SIZE;
$lastBlockSize = $size % $blockSize;
$wholeBlocks = (int) (($size - $lastBlockSize) / $blockSize);
$blockData = self::getRandomData();
for ($i = 0; $i < $wholeBlocks; $i++)
{
fwrite($fp, $blockData);
if (!$reuseBlock)
{
$blockData = self::getRandomData($blockSize);
}
}
if ($lastBlockSize > 0)
{
fwrite($fp, $blockData, $lastBlockSize);
}
fclose($fp);
return $tempFilePath;
}
/**
* Get a writeable temporary folder
*
* @return string
*/
protected static function getTempFolder(): string
{
$tempPath = sys_get_temp_dir();
if (!is_writable($tempPath))
{
$tempPath = __DIR__ . '/tmp';
if (!is_dir($tempPath))
{
@mkdir($tempPath, 0755, true);
}
}
if (!is_writable($tempPath))
{
throw new RuntimeException("Cannot get a writeable temporary path.");
}
return $tempPath;
}
/**
* Checks that two files are of equal length and contents
*
* @param string $referenceFilePath The known, reference file
* @param string $unknownFilePath The file we want to verify is the same as the reference file
*
* @return bool
*/
protected static function areFilesEqual(string $referenceFilePath, string $unknownFilePath): bool
{
if (!file_exists($referenceFilePath) || !file_exists($unknownFilePath))
{
return false;
}
if (!is_file($referenceFilePath) || !is_file($unknownFilePath))
{
return false;
}
if (!is_readable($referenceFilePath) || !is_readable($unknownFilePath))
{
return false;
}
if (@filesize($referenceFilePath) !== @filesize($unknownFilePath))
{
return false;
}
return hash_file(self::FILE_HASHING_ALGORITHM, $referenceFilePath) === hash_file(self::FILE_HASHING_ALGORITHM, $unknownFilePath);
}
/**
* Checks that two strings are of equal length and contents
*
* @param string $referenceString The known, reference file
* @param string $unknownString The file we want to verify is the same as the reference file
*
* @return bool
*/
protected static function areStringsEqual(string $referenceString, string $unknownString): bool
{
return $referenceString === $unknownString;
}
/**
* Returns random data of the specific size in bytes
*
* @param int $length How many bytes of random data to return
*
* @return string Your random data
*/
protected static function getRandomData(int $length = self::BLOCK_SIZE): string
{
$blockData = '';
if (substr(strtolower(PHP_OS), 0, 7) !== 'windows')
{
$fpRandom = @fopen('/dev/urandom', 'r');
if ($fpRandom !== false)
{
$blockData = @fread($fpRandom, $length);
@fclose($fpRandom);
}
}
if (empty($blockData) && function_exists('random_bytes'))
{
try
{
$blockData = random_bytes($length);
}
catch (\Exception $e)
{
$blockData = '';
}
}
if (empty($blockData) && function_exists('openssl_random_pseudo_bytes'))
{
$blockData = openssl_random_pseudo_bytes($length);
}
if (empty($blockData) && function_exists('mcrypt_create_iv'))
{
$blockData = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);
if (empty($blockData))
{
$blockData = mcrypt_create_iv($length, MCRYPT_RAND);
}
}
if (empty($blockData))
{
for ($i = 0; $i < $length; $i++)
{
$blockData .= ord(mt_rand(0, 255));
}
}
return $blockData;
}
protected static function assert(bool $condition, string $message): void
{
if ($condition)
{
return;
}
throw new RuntimeException($message);
}
}

View file

@ -0,0 +1,217 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Akeeba\Engine\Postproc\Connector\S3v4\Input;
/**
* Upload, download and delete big files (over 1MB), without multipart uploads. Uses string or file sources.
*
* @package Akeeba\MiniTest\Test
*/
class BigFiles extends AbstractTest
{
/**
* Should I download the file after uploading it to test for contents consistency?
*
* @var bool
*/
protected static $downloadAfter = true;
/**
* Should I delete the uploaded file after the test case is done?
*
* @var bool
*/
protected static $deleteRemote = true;
/**
* Should I use multipart (chunked) uploads?
*
* @var bool
*/
protected static $multipart = false;
/**
* Chunk size for each multipart upload. Must be at least 5MB or the library overrides us.
*
* @var int
*/
protected static $uploadChunkSize = 5242880;
/**
* Number of uploaded chunks.
*
* This is set by self::upload(). Zero for single part uploads, non-zero for multipart uploads.
*
* @var int
*/
protected static $numberOfChunks = 0;
public static function upload5MBString(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::FIVE_MB, 'bigtest_5mb.dat');
}
public static function upload6MBString(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::SIX_MB, 'bigtest_6mb.dat');
}
public static function upload10MBString(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::TEN_MB, 'bigtest_10mb.dat');
}
public static function upload11MBString(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::ELEVEN_MB, 'bigtest_11mb.dat');
}
public static function upload5MBFile(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::FIVE_MB, 'bigtest_5mb.dat', false);
}
public static function upload6MBFile(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::SIX_MB, 'bigtest_6mb.dat', false);
}
public static function upload10MBFile(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::TEN_MB, 'bigtest_10mb.dat', false);
}
public static function upload11MBFile(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::ELEVEN_MB, 'bigtest_11mb.dat', false);
}
protected static function upload(Connector $s3, array $options, int $size, string $uri, bool $useString = true): bool
{
// Randomize the name. Required for archive buckets where you cannot overwrite data.
$dotPos = strrpos($uri, '.');
$uri = substr($uri, 0, $dotPos) . '.' . md5(microtime(false)) . substr($uri, $dotPos);
self::$numberOfChunks = 0;
if ($useString)
{
$sourceData = self::getRandomData($size);
$input = Input::createFromData($sourceData);
}
else
{
// Create a file with random data
$sourceFile = self::createFile($size);
$input = Input::createFromFile($sourceFile);
}
// Upload the file. Throws exception if it fails.
$bucket = $options['bucket'];
if (!self::$multipart)
{
$s3->putObject($input, $bucket, $uri);
}
else
{
// Get an upload session
$uploadSession = $s3->startMultipart($input, $bucket, $uri);
// This array holds the etags of uploaded parts. Used by finalizeMultipart.
$eTags = [];
$partNumber = 1;
while (true)
{
// We need to create a new input for each upload chunk
if ($useString)
{
$input = Input::createFromData($sourceData);
}
else
{
$input = Input::createFromFile($sourceFile);
}
$input->setUploadID($uploadSession);
$input->setEtags($eTags);
$input->setPartNumber($partNumber);
$etag = $s3->uploadMultipart($input, $bucket, $uri, [], self::$uploadChunkSize);
// If the result was null we have no more file parts to process.
if (is_null($etag))
{
break;
}
// Append the etag to the etags array
$eTags[] = $etag;
// Set the etags array in the Input object (required by finalizeMultipart)
$input->setEtags($eTags);
$partNumber++;
}
self::$numberOfChunks = count($eTags);
// Finalize the multipart upload. Tells Amazon to construct the file from the uploaded parts.
$s3->finalizeMultipart($input, $bucket, $uri);
}
// Tentatively accept that this method succeeded.
$result = true;
// Should I download the file and compare its contents?
if (self::$downloadAfter)
{
if ($useString)
{
// Download the data. Throws exception if it fails.
$downloadedData = $s3->getObject($bucket, $uri);
// Compare the file contents.
$result = self::areStringsEqual($sourceData, $downloadedData);
}
else
{
// Download the data. Throws exception if it fails.
$downloadedFile = tempnam(self::getTempFolder(), 'as3');
$s3->getObject($bucket, $uri, $downloadedFile);
// Compare the file contents.
$result = self::areFilesEqual($sourceFile, $downloadedFile);
@unlink($downloadedFile);
}
}
// Remove the local files
if (!$useString)
{
@unlink($sourceFile);
}
// Should I delete the remotely stored file?
if (self::$deleteRemote)
{
// Delete the remote file. Throws exception if it fails.
$s3->deleteObject($bucket, $uri);
}
return $result;
}
}

View file

@ -0,0 +1,25 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
class BucketLocation extends AbstractTest
{
public static function getBucketLocation(Connector $s3, array $options): bool
{
$location = $s3->getBucketLocation($options['bucket']);
self::assert($location === $options['region'], "Bucket {$options['bucket']} reports being in region {$location} instead of expected {$options['region']}");
return true;
}
}

View file

@ -0,0 +1,52 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use RuntimeException;
class BucketsList extends AbstractTest
{
public static function listBucketsDetailed(Connector $s3, array $options): bool
{
$buckets = $s3->listBuckets(true);
self::assert(is_array($buckets), "Detailed buckets list is not an array");
self::assert(isset($buckets['owner']), "Detailed buckets list does not list an owner");
self::assert(isset($buckets['owner']['id']), "Detailed buckets list does not list an owner's id");
self::assert(isset($buckets['owner']['name']), "Detailed buckets list does not list an owner's name");
self::assert(isset($buckets['buckets']), "Detailed buckets list does not list any buckets");
foreach ($buckets['buckets'] as $bucketInfo)
{
self::assert(isset($bucketInfo['name']), "Bucket information does not list a name");
self::assert(isset($bucketInfo['time']), "Bucket information does not list a created times");
if ($bucketInfo['name'] === $options['bucket'])
{
return true;
}
}
throw new RuntimeException("Detailed buckets list does not include configured bucket {$options['bucket']}");
}
public static function listBucketsSimple(Connector $s3, array $options): bool
{
$buckets = $s3->listBuckets(false);
self::assert(is_array($buckets), "Simple buckets list is not an array");
self::assert(in_array($options['bucket'], $buckets), "Simple buckets list does not include configured bucket {$options['bucket']}");
return true;
}
}

View file

@ -0,0 +1,308 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Akeeba\Engine\Postproc\Connector\S3v4\Exception\CannotPutFile;
use Akeeba\Engine\Postproc\Connector\S3v4\Input;
class ListFiles extends AbstractTest
{
private static $paths = [
'listtest_one.dat',
'listtest_two.dat',
'listtest_three.dat',
'list_deeper/test_one.dat',
'list_deeper/test_two.dat',
'list_deeper/test_three.dat',
'list_deeper/listtest_four.dat',
'list_deeper/listtest_five.dat',
'list_deeper/listtest_six.dat',
'list_deeper/spam.dat',
'list_deeper/listtest_deeper/seven.dat',
'list_deeper/listtest_deeper/eight.dat',
'spam.dat',
];
public static function setup(Connector $s3, array $options): void
{
$data = self::getRandomData(self::TEN_KB);
foreach (self::$paths as $uri)
{
$input = Input::createFromData($data);
try
{
$s3->putObject($input, $options['bucket'], $uri);
}
catch (CannotPutFile $e)
{
// Expected for archival buckets
}
}
}
public static function teardown(Connector $s3, array $options): void
{
foreach (self::$paths as $uri)
{
try
{
$s3->deleteObject($options['bucket'], $uri);
}
catch (\Exception $e)
{
// No problem if I can't delete the file
}
}
}
public static function testGetAll(Connector $s3, array $options): bool
{
$listing = $s3->getBucket($options['bucket'], 'listtest_');
self::assert(is_array($listing), "The files listing must be an array");
self::assert(count($listing) == 3, "I am expecting to see 3 files");
// Make sure I have the expected files
self::assert(array_key_exists('listtest_one.dat', $listing), "File listtest_one.dat not in listing");
self::assert(array_key_exists('listtest_two.dat', $listing), "File listtest_two.dat not in listing");
self::assert(array_key_exists('listtest_three.dat', $listing), "File listtest_three.dat not in listing");
// I must not see the files in subdirectories
self::assert(!array_key_exists('listtest_four.dat', $listing), "File listtest_four.dat in listing");
self::assert(!array_key_exists('listtest_five.dat', $listing), "File listtest_five.dat in listing");
self::assert(!array_key_exists('listtest_six.dat', $listing), "File listtest_six.dat in listing");
// I must not see the files not matching the prefix I gave
self::assert(!array_key_exists('spam.dat', $listing), "File spam.dat in listing");
self::assert(!array_key_exists('ham.dat', $listing), "File ham.dat in listing");
foreach ($listing as $fileName => $info)
{
self::assert(isset($info['name']), "File entries must have a name");
self::assert(isset($info['time']), "File entries must have a time");
self::assert(isset($info['size']), "File entries must have a size");
self::assert(isset($info['hash']), "File entries must have a hash");
}
return true;
}
public static function testGetContinue(Connector $s3, array $options): bool
{
$listing = $s3->getBucket($options['bucket'], 'listtest_', null, 1);
self::assert(is_array($listing), "The files listing must be an array");
self::assert(count($listing) == 1, sprintf("I am expecting to see 1 file, %s seen", count($listing)));
$files = array_keys($listing);
$continued = $s3->getBucket($options['bucket'], 'listtest_', array_shift($files));
self::assert(is_array($continued), "The continued files listing must be an array");
self::assert(count($continued) == 2, sprintf("I am expecting to see 2 files, %s seen", count($continued)));
$listing = array_merge($listing, $continued);
// Make sure I have the expected files
self::assert(array_key_exists('listtest_one.dat', $listing), "File listtest_one.dat not in listing");
self::assert(array_key_exists('listtest_two.dat', $listing), "File listtest_two.dat not in listing");
self::assert(array_key_exists('listtest_three.dat', $listing), "File listtest_three.dat not in listing");
// I must not see the files in subdirectories
self::assert(!array_key_exists('listtest_four.dat', $listing), "File listtest_four.dat in listing");
self::assert(!array_key_exists('listtest_five.dat', $listing), "File listtest_five.dat in listing");
self::assert(!array_key_exists('listtest_six.dat', $listing), "File listtest_six.dat in listing");
// I must not see the files not matching the prefix I gave
self::assert(!array_key_exists('spam.dat', $listing), "File spam.dat in listing");
self::assert(!array_key_exists('ham.dat', $listing), "File ham.dat in listing");
foreach ($listing as $fileName => $info)
{
self::assert(isset($info['name']), "File entries must have a name");
self::assert(isset($info['time']), "File entries must have a time");
self::assert(isset($info['size']), "File entries must have a size");
self::assert(isset($info['hash']), "File entries must have a hash");
}
return true;
}
public static function testGetSubdirectoryFiles(Connector $s3, array $options): bool
{
$listing = $s3->getBucket($options['bucket'], 'list_deeper/test_');
self::assert(is_array($listing), "The files listing must be an array");
self::assert(count($listing) == 3, "I am expecting to see 3 files");
// Make sure I have the expected files
self::assert(array_key_exists('list_deeper/test_one.dat', $listing), "File test_one.dat not in listing");
self::assert(array_key_exists('list_deeper/test_two.dat', $listing), "File test_two.dat not in listing");
self::assert(array_key_exists('list_deeper/test_three.dat', $listing), "File test_three.dat not in listing");
// I must not see the files with different prefix
self::assert(!array_key_exists('list_deeper/listtest_four.dat', $listing), "File listtest_four.dat in listing");
self::assert(!array_key_exists('list_deeper/listtest_five.dat', $listing), "File listtest_five.dat in listing");
self::assert(!array_key_exists('list_deeper/listtest_six.dat', $listing), "File listtest_six.dat in listing");
self::assert(!array_key_exists('list_deeper/spam.dat', $listing), "File spam.dat in listing");
// I must not see the files in subdirectories
self::assert(!array_key_exists('list_deeper/listtest_deeper/seven.dat', $listing), "File spam.dat in listing");
self::assert(!array_key_exists('list_deeper/listtest_deeper/eight.dat', $listing), "File spam.dat in listing");
foreach ($listing as $fileName => $info)
{
self::assert(isset($info['name']), "File entries must have a name");
self::assert(isset($info['time']), "File entries must have a time");
self::assert(isset($info['size']), "File entries must have a size");
self::assert(isset($info['hash']), "File entries must have a hash");
}
return true;
}
public static function testGetSubdirectoryFilesWithContinue(Connector $s3, array $options): bool
{
$listing = $s3->getBucket($options['bucket'], 'list_deeper/test_', null, 1);
self::assert(is_array($listing), "The files listing must be an array");
self::assert(count($listing) == 1, sprintf("I am expecting to see 1 file, %s seen", count($listing)));
$files = array_keys($listing);
$continued = $s3->getBucket($options['bucket'], 'list_deeper/test_', array_shift($files));
self::assert(is_array($continued), "The continued files listing must be an array");
self::assert(count($continued) == 2, sprintf("I am expecting to see 2 files, %s seen", count($continued)));
$listing = array_merge($listing, $continued);
self::assert(is_array($listing), "The files listing must be an array");
self::assert(count($listing) == 3, "I am expecting to see 3 files");
// Make sure I have the expected files
self::assert(array_key_exists('list_deeper/test_one.dat', $listing), "File test_one.dat not in listing");
self::assert(array_key_exists('list_deeper/test_two.dat', $listing), "File test_two.dat not in listing");
self::assert(array_key_exists('list_deeper/test_three.dat', $listing), "File test_three.dat not in listing");
// I must not see the files with different prefix
self::assert(!array_key_exists('list_deeper/listtest_four.dat', $listing), "File listtest_four.dat in listing");
self::assert(!array_key_exists('list_deeper/listtest_five.dat', $listing), "File listtest_five.dat in listing");
self::assert(!array_key_exists('list_deeper/listtest_six.dat', $listing), "File listtest_six.dat in listing");
self::assert(!array_key_exists('list_deeper/spam.dat', $listing), "File spam.dat in listing");
// I must not see the files in subdirectories
self::assert(!array_key_exists('list_deeper/listtest_deeper/seven.dat', $listing), "File spam.dat in listing");
self::assert(!array_key_exists('list_deeper/listtest_deeper/eight.dat', $listing), "File spam.dat in listing");
foreach ($listing as $fileName => $info)
{
self::assert(isset($info['name']), "File entries must have a name");
self::assert(isset($info['time']), "File entries must have a time");
self::assert(isset($info['size']), "File entries must have a size");
self::assert(isset($info['hash']), "File entries must have a hash");
}
return true;
}
public static function testListWithPrefixSharedWithFolder(Connector $s3, array $options): bool
{
/**
* The prefix list_deeper/listtest_ matches BOTH keys (files) and common prefixes (folders).
*
* Common prefixes have priority so the first request would return zero files. The Connector catches that
* internally and performs more requests until it has at least as many files as we requeted.
*/
$listing = $s3->getBucket($options['bucket'], 'list_deeper/listtest_', null, 1);
self::assert(is_array($listing), "The files listing must be an array");
self::assert(count($listing) == 1, sprintf("I am expecting to see 1 files, %s seen", count($listing)));
$files = array_keys($listing);
$continued = $s3->getBucket($options['bucket'], 'list_deeper/listtest_', array_shift($files));
self::assert(is_array($continued), "The continued files listing must be an array");
self::assert(count($continued) == 2, sprintf("I am expecting to see 2 files, %s seen", count($continued)));
$listing = array_merge($listing, $continued);
self::assert(is_array($listing), "The files listing must be an array");
self::assert(count($listing) == 3, "I am expecting to see 3 files");
// Make sure I have the expected files
self::assert(array_key_exists('list_deeper/listtest_four.dat', $listing), "File listtest_four.dat not in listing");
self::assert(array_key_exists('list_deeper/listtest_five.dat', $listing), "File listtest_five.dat not in listing");
self::assert(array_key_exists('list_deeper/listtest_six.dat', $listing), "File listtest_six.dat not in listing");
// I must not see the files with different prefix
self::assert(!array_key_exists('list_deeper/test_one.dat', $listing), "File test_one.dat in listing");
self::assert(!array_key_exists('list_deeper/test_two.dat', $listing), "File test_two.dat in listing");
self::assert(!array_key_exists('list_deeper/test_three.dat', $listing), "File test_three.dat in listing");
self::assert(!array_key_exists('list_deeper/spam.dat', $listing), "File spam.dat in listing");
// I must not see the files in subdirectories
self::assert(!array_key_exists('list_deeper/listtest_deeper/seven.dat', $listing), "File spam.dat in listing");
self::assert(!array_key_exists('list_deeper/listtest_deeper/eight.dat', $listing), "File spam.dat in listing");
foreach ($listing as $fileName => $info)
{
self::assert(isset($info['name']), "File entries must have a name");
self::assert(isset($info['time']), "File entries must have a time");
self::assert(isset($info['size']), "File entries must have a size");
self::assert(isset($info['hash']), "File entries must have a hash");
}
return true;
}
public static function testCommonPrefixes(Connector $s3, array $options): bool
{
$listing = $s3->getBucket($options['bucket'], 'list_deeper/listtest_', null, null, '/', true);
self::assert(is_array($listing), "The files listing must be an array");
self::assert(count($listing) == 4, sprintf("I am expecting to see 4 entries, %s entries seen.", count($listing)));
// Make sure I have the expected files
self::assert(array_key_exists('list_deeper/listtest_four.dat', $listing), "File listtest_four.dat not in listing");
self::assert(array_key_exists('list_deeper/listtest_five.dat', $listing), "File listtest_five.dat not in listing");
self::assert(array_key_exists('list_deeper/listtest_six.dat', $listing), "File listtest_six.dat not in listing");
self::assert(array_key_exists('list_deeper/listtest_deeper/', $listing), "Folder listtest_deeper not in listing");
// I must not see the files in subdirectories
self::assert(!array_key_exists('list_deeper/listtest_deeper/seven.dat', $listing), "File seven.dat in listing");
self::assert(!array_key_exists('list_deeper/listtest_deeper/eight.dat', $listing), "File eight.dat in listing");
// I must not see the files with different prefix
self::assert(!array_key_exists('list_deeper/spam.dat', $listing), "File spam.dat in listing");
self::assert(!array_key_exists('list_deeper/test_one.dat', $listing), "File test_one.dat not in listing");
self::assert(!array_key_exists('list_deeper/test_two.dat', $listing), "File test_two.dat not in listing");
self::assert(!array_key_exists('list_deeper/test_three.dat', $listing), "File test_three.dat not in listing");
foreach ($listing as $fileName => $info)
{
if (substr($fileName, -1) !== '/')
{
self::assert(isset($info['name']), "File entries must have a name");
self::assert(isset($info['time']), "File entries must have a time");
self::assert(isset($info['size']), "File entries must have a size");
self::assert(isset($info['hash']), "File entries must have a hash");
}
else
{
self::assert(isset($info['prefix']), "Folder entries must return a prefix");
}
}
return true;
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace Akeeba\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
class Multipart extends BigFiles
{
public static function setup(Connector $s3, array $options): void
{
self::$multipart = true;
parent::setup($s3, $options);
}
public static function upload5MBString(Connector $s3, array $options): bool
{
$result = parent::upload5MBString($s3, $options);
$expectedChunks = 1;
self::assert(self::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, self::$numberOfChunks));
return $result;
}
public static function upload6MBString(Connector $s3, array $options): bool
{
$result = parent::upload6MBString($s3, $options);
$expectedChunks = 2;
self::assert(self::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, self::$numberOfChunks));
return $result;
}
public static function upload10MBString(Connector $s3, array $options): bool
{
$result = parent::upload10MBString($s3, $options);
$expectedChunks = 2;
self::assert(self::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, self::$numberOfChunks));
return $result;
}
public static function upload11MBString(Connector $s3, array $options): bool
{
$result = parent::upload11MBString($s3, $options);
$expectedChunks = 3;
self::assert(self::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, self::$numberOfChunks));
return $result;
}
public static function upload5MBFile(Connector $s3, array $options): bool
{
$result = parent::upload5MBFile($s3, $options);
$expectedChunks = 1;
self::assert(self::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, self::$numberOfChunks));
return $result;
}
public static function upload6MBFile(Connector $s3, array $options): bool
{
$result = parent::upload6MBFile($s3, $options);
$expectedChunks = 2;
self::assert(self::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, self::$numberOfChunks));
return $result;
}
public static function upload10MBFile(Connector $s3, array $options): bool
{
$result = parent::upload10MBFile($s3, $options);
$expectedChunks = 2;
self::assert(self::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, self::$numberOfChunks));
return $result;
}
public static function upload11MBFile(Connector $s3, array $options): bool
{
$result = parent::upload11MBFile($s3, $options);
$expectedChunks = 3;
self::assert(self::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, self::$numberOfChunks));
return $result;
}
}

View file

@ -0,0 +1,59 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Acl;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Akeeba\Engine\Postproc\Connector\S3v4\Input;
use RuntimeException;
class SignedURLs extends AbstractTest
{
public static function signedURLPublicObject(Connector $s3, array $options): bool
{
return self::signedURL($s3, $options, Acl::ACL_PUBLIC_READ);
}
public static function signedURLPrivateObject(Connector $s3, array $options): bool
{
return self::signedURL($s3, $options, Acl::ACL_PRIVATE);
}
private static function signedURL(Connector $s3, array $options, string $aclPrivilege): bool
{
$tempData = self::getRandomData(AbstractTest::TEN_KB);
$input = Input::createFromData($tempData);
$uri = 'test.' . md5(microtime(false)) . '.dat';
$s3->putObject($input, $options['bucket'], $uri, $aclPrivilege);
$downloadURL = $s3->getAuthenticatedURL($options['bucket'], $uri, null, $options['ssl']);
$downloadedData = @file_get_contents($downloadURL);
try
{
$s3->deleteObject($options['bucket'], $uri);
}
catch (\Exception $e)
{
// Ignore deletion errors
}
if ($downloadedData === false)
{
throw new RuntimeException("Failed to download from signed URL {$downloadURL}");
}
self::assert(self::areStringsEqual($tempData, $downloadedData), "Wrong data received from signed URL {$downloadURL}");
return true;
}
}

View file

@ -0,0 +1,109 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Akeeba\Engine\Postproc\Connector\S3v4\Input;
/**
* Upload, download and delete small files (under 1MB) using a file source
*
* @package Akeeba\MiniTest\Test
*/
class SmallFiles extends AbstractTest
{
/**
* Should I download the file after uploading it to test for contents consistency?
*
* @var bool
*/
protected static $downloadAfter = true;
/**
* Should I delete the uploaded file after the test case is done?
*
* @var bool
*/
protected static $deleteRemote = true;
public static function upload10KbRoot(Connector $s3, array $options): bool
{
return self::upload($s3, $options, AbstractTest::TEN_KB, 'root_10kb.dat');
}
public static function upload10KbRootGreek(Connector $s3, array $options): bool
{
return self::upload($s3, $options, AbstractTest::TEN_KB, οκιμή_10kb.dat');
}
public static function upload10KbFolderGreek(Connector $s3, array $options): bool
{
return self::upload($s3, $options, AbstractTest::TEN_KB, 'ο_φάκελός_μουοκιμή_10kb.dat');
}
public static function upload600KbRoot(Connector $s3, array $options): bool
{
return self::upload($s3, $options, AbstractTest::SIX_HUNDRED_KB, 'root_600kb.dat');
}
public static function upload10KbFolder(Connector $s3, array $options): bool
{
return self::upload($s3, $options, AbstractTest::TEN_KB, 'my_folder/10kb.dat');
}
public static function upload600KbFolder(Connector $s3, array $options): bool
{
return self::upload($s3, $options, AbstractTest::SIX_HUNDRED_KB, 'my_folder/600kb.dat');
}
protected static function upload(Connector $s3, array $options, int $size, string $uri): bool
{
// Randomize the name. Required for archive buckets where you cannot overwrite data.
$dotPos = strrpos($uri, '.');
$uri = substr($uri, 0, $dotPos) . '.' . md5(microtime(false)) . substr($uri, $dotPos);
// Create a file with random data
$sourceFile = self::createFile($size);
// Upload the file. Throws exception if it fails.
$bucket = $options['bucket'];
$input = Input::createFromFile($sourceFile);
$s3->putObject($input, $bucket, $uri);
// Tentatively accept that this method succeeded.
$result = true;
// Should I download the file and compare its contents?
if (self::$downloadAfter)
{
// Donwload the data. Throws exception if it fails.
$downloadedFile = tempnam(self::getTempFolder(), 'as3');
$s3->getObject($bucket, $uri, $downloadedFile);
// Compare the file contents.
$result = self::areFilesEqual($sourceFile, $downloadedFile);
}
// Remove the local files
@unlink($sourceFile);
@unlink($downloadedFile);
// Should I delete the remotely stored file?
if (self::$deleteRemote)
{
// Delete the remote file. Throws exception if it fails.
$s3->deleteObject($bucket, $uri);
}
return $result;
}
}

View file

@ -0,0 +1,28 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
/**
* Upload and download small files (under 1MB) using a file source
*
* @package Akeeba\MiniTest\Test
*/
class SmallFilesNoDelete extends SmallFiles
{
public static function setup(Connector $s3, array $options): void
{
self::$deleteRemote = false;
parent::setup($s3, $options);
}
}

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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
/**
* Upload small files (under 1MB) using a file source
*
* @package Akeeba\MiniTest\Test
*/
class SmallFilesOnlyUpload extends SmallFiles
{
public static function setup(Connector $s3, array $options): void
{
self::$deleteRemote = false;
self::$downloadAfter = false;
parent::setup($s3, $options);
}
}

View file

@ -0,0 +1,58 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Akeeba\Engine\Postproc\Connector\S3v4\Input;
/**
* Upload, download and delete small files (under 1MB) using a string source
*
* @package Akeeba\MiniTest\Test
*/
class SmallInlineFiles extends SmallFiles
{
protected static function upload(Connector $s3, array $options, int $size, string $uri): bool
{
// Randomize the name. Required for archive buckets where you cannot overwrite data.
$dotPos = strrpos($uri, '.');
$uri = substr($uri, 0, $dotPos) . '.' . md5(microtime(false)) . substr($uri, $dotPos);
// Create some random data to upload
$sourceData = self::getRandomData($size);
// Upload the data. Throws exception if it fails.
$bucket = $options['bucket'];
$input = Input::createFromData($sourceData);
$s3->putObject($input, $bucket, $uri);
// Tentatively accept that this method succeeded.
$result = true;
// Should I download the file and compare its contents with my random data?
if (self::$downloadAfter)
{
$downloadedData = $s3->getObject($bucket, $uri);
$result = self::areStringsEqual($sourceData, $downloadedData);
}
// Should I delete the remotely stored file?
if (self::$deleteRemote)
{
// Delete the remote file. Throws exception if it fails.
$s3->deleteObject($bucket, $uri);
}
return $result;
}
}

View file

@ -0,0 +1,28 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
/**
* Upload and download small files (under 1MB) using a string source
*
* @package Akeeba\MiniTest\Test
*/
class SmallInlineFilesNoDelete extends SmallInlineFiles
{
public static function setup(Connector $s3, array $options): void
{
self:: $deleteRemote = false;
parent::setup($s3, $options);
}
}

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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
/**
* Upload small files (under 1MB) using a string source
*
* @package Akeeba\MiniTest\Test
*/
class SmallInlineFilesOnlyUpload extends SmallInlineFiles
{
public static function setup(Connector $s3, array $options): void
{
self::$deleteRemote = false;
self::$downloadAfter = false;
parent::setup($s3, $options);
}
}

View file

@ -0,0 +1,74 @@
<?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\MiniTest\Test;
use Akeeba\Engine\Postproc\Connector\S3v4\Acl;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Akeeba\Engine\Postproc\Connector\S3v4\Input;
use Akeeba\Engine\Postproc\Connector\S3v4\StorageClass;
class StorageClasses extends AbstractTest
{
protected static $downloadAfter = true;
protected static $deleteRemote = true;
public static function uploadRRS(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::TEN_KB, 'rrs_test_10kb.dat', StorageClass::REDUCED_REDUNDANCY);
}
public static function uploadIntelligentTiering(Connector $s3, array $options): bool
{
return self::upload($s3, $options, self::TEN_KB, 'rrs_test_10kb.dat', StorageClass::INTELLIGENT_TIERING);
}
protected static function upload(Connector $s3, array $options, int $size, string $uri, string $storageClass = null)
{
// Randomize the name. Required for archive buckets where you cannot overwrite data.
$dotPos = strrpos($uri, '.');
$uri = substr($uri, 0, $dotPos) . '.' . md5(microtime(false)) . substr($uri, $dotPos);
// Create some random data to upload
$sourceData = self::getRandomData($size);
// Upload the data. Throws exception if it fails.
$bucket = $options['bucket'];
$input = Input::createFromData($sourceData);
// Get the headers
$headers = [];
StorageClass::setStorageClass($headers, $storageClass);
$s3->putObject($input, $bucket, $uri, Acl::ACL_PRIVATE, $headers);
// Tentatively accept that this method succeeded.
$result = true;
// Should I download the file and compare its contents with my random data?
if (self::$downloadAfter)
{
$downloadedData = $s3->getObject($bucket, $uri);
$result = self::areStringsEqual($sourceData, $downloadedData);
}
// Should I delete the remotely stored file?
if (self::$deleteRemote)
{
// Delete the remote file. Throws exception if it fails.
$s3->deleteObject($bucket, $uri);
}
return $result;
}
}

View file

@ -0,0 +1,169 @@
<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
// Default Amazon S3 Access Key
define('DEFAULT_ACCESS_KEY', 'your s3 access key');
// Default Amazon S3 Secret Key
define('DEFAULT_SECRET_KEY', 'your secret key');
// Default region for the bucket
define('DEFAULT_REGION', 'us-east-1');
// Default bucket name
define('DEFAULT_BUCKET', 'example');
// Default signature method (v4 or v2)
define('DEFAULT_SIGNATURE', 'v4');
// Use Dualstack unless otherwise specified?
define('DEFAULT_DUALSTACK', false);
// Use legacy path access by default?
define('DEFAULT_PATH_ACCESS', false);
// Should I use SSL by default?
define('DEFAULT_SSL', true);
/**
* Tests for standard key pairs allowing us to read, write and delete
*
* This is the main test suite
*/
$standardTests = [
'BucketsList',
'BucketLocation',
'SmallFiles',
'SmallInlineFiles',
'SignedURLs',
'StorageClasses',
'ListFiles',
'BigFiles',
'Multipart',
];
/**
* Tests for key pairs or buckets which do NOT allow us to delete, but DO allow us to write and read data
*
* Example: archival buckets
*/
$noDeleteTests = [
'SmallFilesNoDelete',
'SmallInlineFilesNoDelete',
];
/**
* Tests for key pairs which do NOT allow us to read, but DO allow us to write/delete
*
* Example: write-only key pairs per my documentation information from 2011 :)
*/
$writeOnlyTests = [
'SmallFilesOnlyUpload',
'SmallInlineFilesOnlyUpload',
];
/**
* These are the individual test configurations.
*
* Each configuration consists of two keys:
*
* * **configuration** Overrides to the default configuration.
* * **tests** The names of the test classes to execute. Use the format ['classname', 'method'] to execute
* specific test methods only.
*/
$testConfigurations = [
// Format of each
// 'Description of this configuration' => array(
// 'configuration' => array(
// // You can skip one or more keys. The defaults will be used.
// 'access' => 'a different access key',
// 'secret' => 'a different secret key',
// 'region' => 'eu-west-1',
// 'bucket' => 'different_example',
// 'signature' => 'v2',
// 'dualstack' => true,
// 'path_access' => true,
// 'ssl' => true,
// // Only if you want to use a custom, non-Amazon endpoint
// 'endpoint' => null,
// ),
// 'tests' => array(
// // Use a start to run all tests
// '*',
// // Alternatively you can define single test classes:
// 'SmallFiles',
// // ..or specific tests:
// array('SmallFiles', 'upload10KbRoot'),
// )
// ),
/**/
/**
* These are the standard tests we run for each region and key pair.
*
* For all available regions please consult https://docs.aws.amazon.com/general/latest/gr/s3.html
*
* It is recommended to run against the following regions:
* - eu-east-1 The original Amazon S3 region, it often has special meaning in APIs.
* - eu-west-1 Ireland. The original EU region for S3, as a test for the non-default region.
* - eu-central-1 Frankfurt. This region like all newer regions only allows v4 signatures!
* - cn-north-1 Beijing, China. Requires running it from inside China.
* - NON-AMAZON A custom endpoint for a third party, S3-compatible API. Ideally one for v2 and one for v4.
*
* Further to that test the following:
* - Write-only, bucket-restricted keys
* - Read/write, bucket-restricted keys
* - Buckets with dots
* - Buckets with uppercase letters
* - Buckets with international letters
* - Access from within EC2
*/
'Global key, v4, DNS, single stack' => [
'configuration' => [
'signature' => 'v4',
'dualstack' => false,
'path_access' => false,
],
'tests' => $standardTests,
],
'Global key, v4, DNS, dual stack' => [
'configuration' => [
'signature' => 'v4',
'dualstack' => true,
'path_access' => false,
],
'tests' => $standardTests,
],
'Global key, v4, path, single stack' => [
'configuration' => [
'signature' => 'v4',
'dualstack' => false,
'path_access' => true,
],
'tests' => $standardTests,
],
'Global key, v4, path, dual stack' => [
'configuration' => [
'signature' => 'v4',
'dualstack' => true,
'path_access' => true,
],
'tests' => $standardTests,
],
'Global key, v2, DNS, single stack' => [
'configuration' => [
'signature' => 'v2',
'dualstack' => false,
'path_access' => false,
],
'tests' => $standardTests,
],
'Global key, v2, DNS, dual stack' => [
'configuration' => [
'signature' => 'v2',
'dualstack' => true,
'path_access' => false,
],
'tests' => $standardTests,
],
];

View file

@ -0,0 +1,396 @@
<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
use Akeeba\Engine\Postproc\Connector\S3v4\Configuration;
use Akeeba\Engine\Postproc\Connector\S3v4\Connector;
use Akeeba\Engine\Postproc\Connector\S3v4\Input;
// Necessary for including the library
define('AKEEBAENGINE', 1);
if (!file_exists(__DIR__ . '/../vendor/autoload.php'))
{
die ('Please run composer install before running the mini test suite.');
}
// Use Composer's autoloader to load the library
/** @var \Composer\Autoload\ClassLoader $autoloader */
$autoloader = require_once(__DIR__ . '/../vendor/autoload.php');
// Add the minitest PSR-4 path map to Composer's autoloader
$autoloader->addPsr4('Akeeba\\MiniTest\\', __DIR__);
function getAllTestClasses(): array
{
static $testClasses = [];
if (!empty($testClasses))
{
return $testClasses;
}
$folder = __DIR__ . '/Test';
$di = new DirectoryIterator($folder);
foreach ($di as $entry)
{
if ($entry->isDot() || !$entry->isFile())
{
continue;
}
$baseName = $entry->getBasename('.php');
$className = '\\Akeeba\\MiniTest\\Test\\' . $baseName;
if (!class_exists($className))
{
continue;
}
$reflectedClass = new ReflectionClass($className);
if ($reflectedClass->isAbstract())
{
continue;
}
$testClasses[] = $className;
}
return $testClasses;
}
function getTestMethods(string $className): array
{
static $classMethodMap = [];
if (isset($classMethodMap[$className]))
{
return $classMethodMap[$className];
}
$classMethodMap[$className] = [];
if (!class_exists($className))
{
return $classMethodMap[$className];
}
$reflectedClass = new ReflectionClass($className);
$methods = $reflectedClass->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC);
$classMethodMap[$className] = array_map(function (ReflectionMethod $refMethod) {
if ($refMethod->isPrivate() || $refMethod->isProtected() || $refMethod->isAbstract())
{
return null;
}
if (!$refMethod->isStatic())
{
return null;
}
return $refMethod->getName();
}, $methods);
$classMethodMap[$className] = array_filter($classMethodMap[$className], function ($method) {
if (is_null($method))
{
return false;
}
if (in_array($method, ['setup', 'teardown']))
{
return false;
}
return true;
});
return $classMethodMap[$className];
}
function simplifyClassName(?string $className): string
{
if (empty($className))
{
return '';
}
$namespace = __NAMESPACE__ . '\\Test\\';
if (strpos($className, $namespace) === 0)
{
return substr($className, strlen($namespace));
}
return $className;
}
if (!file_exists(__DIR__ . '/config.php'))
{
die ('Please rename config.dist.php to config.php and customise it before running the mini test suite.');
}
require __DIR__ . '/config.php';
global $testConfigurations;
$total = 0;
$broken = 0;
$failed = 0;
$successful = 0;
foreach ($testConfigurations as $description => $setup)
{
echo "" . $description . PHP_EOL;
echo str_repeat('〰', 80) . PHP_EOL . PHP_EOL;
// Extract the configuration options
if (!isset($setup['configuration']))
{
$setup['configuration'] = [];
}
$configOptions = array_merge([
'access' => DEFAULT_ACCESS_KEY,
'secret' => DEFAULT_SECRET_KEY,
'region' => DEFAULT_REGION,
'bucket' => DEFAULT_BUCKET,
'signature' => DEFAULT_SIGNATURE,
'dualstack' => DEFAULT_DUALSTACK,
'path_access' => DEFAULT_PATH_ACCESS,
'ssl' => DEFAULT_SSL,
'endpoint' => null,
], $setup['configuration']);
// Extract the test classes/methods to run
if (!isset($setup['tests']))
{
$setup['tests'] = getAllTestClasses();
}
$tests = $setup['tests'];
if (!is_array($tests) || (is_array($tests) && in_array('*', $tests)))
{
$tests = getAllTestClasses();
}
// Create the S3 configuration object
$s3Configuration = new Configuration($configOptions['access'], $configOptions['secret'], $configOptions['signature'], $configOptions['region']);
$s3Configuration->setUseDualstackUrl($configOptions['dualstack']);
$s3Configuration->setUseLegacyPathStyle($configOptions['path_access']);
$s3Configuration->setSSL($configOptions['ssl']);
if (!is_null($configOptions['endpoint']))
{
$s3Configuration->setEndpoint($configOptions['endpoint']);
}
// Create the connector object
$s3Connector = new Connector($s3Configuration);
// Run the tests
foreach ($tests as $testInfo)
{
if (!is_array($testInfo))
{
$className = $testInfo;
if (!class_exists($className))
{
$className = '\\Akeeba\\MiniTest\\Test\\' . $className;
}
if (!class_exists($className))
{
$total++;
$broken++;
echo " ⁉️ Test class {$className} not found." . PHP_EOL;
continue;
}
$testInfo = array_map(function ($method) use ($className) {
return [$className, $method];
}, getTestMethods($className));
}
else
{
[$className, $method] = $testInfo;
if (!class_exists($className))
{
$className = '\\Akeeba\\MiniTest\\Test\\' . $className;
}
if (!class_exists($className))
{
$total++;
$broken++;
echo " ⁉️ Test class {$className} not found." . PHP_EOL;
continue;
}
$testInfo = [
[$className, $method],
];
}
$firstOne = false;
$className = null;
$callableSetup = null;
$callableTeardown = null;
$simplifiedClassname = simplifyClassName($className);
if (!empty($testInfo))
{
$firstOne = array_shift($testInfo);
array_unshift($testInfo, $firstOne);
}
if ($firstOne)
{
[$className,] = $firstOne;
if ($className)
{
$callableSetup = [$className, 'setup'];
$callableTeardown = [$className, 'teardown'];
}
}
if (is_callable($callableSetup))
{
[$classNameSetup, $method] = $callableSetup;
$simplifiedClassname = simplifyClassName($classNameSetup);
echo " ⏱ Preparing {$simplifiedClassname}:{$method}";
call_user_func($callableSetup, $s3Connector, $configOptions);
echo "\r Prepared {$simplifiedClassname} " . PHP_EOL;
}
foreach ($testInfo as $callable)
{
$total++;
[$className, $method] = $callable;
if (!class_exists($className))
{
$broken++;
echo " ⁉️ Test class {$className} not found." . PHP_EOL;
continue;
}
if (!method_exists($className, $method))
{
$broken++;
echo " ⁉️ Method {$method} not found in test class {$className}." . PHP_EOL;
continue;
}
echo "{$simplifiedClassname}:{$method}";
$errorException = null;
try
{
$result = call_user_func([$className, $method], $s3Connector, $configOptions);
}
catch (Exception $e)
{
$result = false;
$errorException = $e;
}
echo "\r " . ($result ? '✔' : '🚨') . " {$simplifiedClassname}:{$method} " . PHP_EOL;
if ($result)
{
$successful++;
continue;
}
$failed++;
if (is_null($errorException))
{
echo " Returned false" . PHP_EOL;
continue;
}
echo " {$errorException->getCode()} {$errorException->getMessage()}" . PHP_EOL;
echo " {$errorException->getFile()}({$errorException->getLine()})" . PHP_EOL . PHP_EOL;
$errorLines = explode("\n", $errorException->getTraceAsString());
foreach ($errorLines as $line)
{
echo " $line" . PHP_EOL;
}
}
if (is_callable($callableTeardown))
{
[$className, $method] = $callableSetup;
echo " ⏱ Tearing down {$className}:{$method}";
call_user_func($callableTeardown, $s3Connector, $configOptions);
echo "\r Teared down {$className} " . PHP_EOL;
}
}
echo PHP_EOL;
}
echo PHP_EOL;
echo str_repeat('⎺', 80) . PHP_EOL;
echo PHP_EOL;
echo "Summary:" . PHP_EOL;
if ($broken)
{
echo " Broken : $broken" . PHP_EOL;
}
if ($failed)
{
echo " Failed : $failed" . PHP_EOL;
}
if ($successful)
{
echo " Successful : $successful" . PHP_EOL;
}
echo " Total : $total" . PHP_EOL . PHP_EOL;
echo "Conclusion: " . PHP_EOL . " ";
if ($failed > 0)
{
echo "❌ FAILED 😭😭😭" . PHP_EOL;
exit(1);
}
if ($successful === 0)
{
echo "🔥 No tests executed! 🤪" . PHP_EOL;
exit (3);
}
if ($broken > 0)
{
echo "⁉️ SUCCESS but some tests are broken 🤦" . PHP_EOL;
exit (2);
}
echo "✅ PASSED" . PHP_EOL;

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

7
s3_storage/vendor/autoload.php vendored Normal file
View file

@ -0,0 +1,7 @@
<?php
// autoload.php @generated by Composer
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInitS3StorageAddon::getLoader();

View file

@ -0,0 +1,445 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see http://www.php-fig.org/psr/psr-0/
* @see http://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
// PSR-4
private $prefixLengthsPsr4 = array();
private $prefixDirsPsr4 = array();
private $fallbackDirsPsr4 = array();
// PSR-0
private $prefixesPsr0 = array();
private $fallbackDirsPsr0 = array();
private $useIncludePath = false;
private $classMap = array();
private $classMapAuthoritative = false;
private $missingClasses = array();
private $apcuPrefix;
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array $classMap Class to filename map
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param array|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*/
public function add($prefix, $paths, $prepend = false)
{
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
(array) $paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
(array) $paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = (array) $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
(array) $paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
(array) $paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param array|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
(array) $paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
(array) $paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
(array) $paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
(array) $paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param array|string $paths The PSR-0 base directories
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param array|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
}
/**
* Unregisters this instance as an autoloader.
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return bool|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
includeFile($file);
return true;
}
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath . '\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*/
function includeFile($file)
{
include $file;
}

21
s3_storage/vendor/composer/LICENSE vendored Normal file
View file

@ -0,0 +1,21 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -0,0 +1,36 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);
return array(
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Acl' => $vendorDir . '/akeeba/s3/src/Acl.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Configuration' => $vendorDir . '/akeeba/s3/src/Configuration.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Connector' => $vendorDir . '/akeeba/s3/src/Connector.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotDeleteFile' => $vendorDir . '/akeeba/s3/src/Exception/CannotDeleteFile.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotGetBucket' => $vendorDir . '/akeeba/s3/src/Exception/CannotGetBucket.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotGetFile' => $vendorDir . '/akeeba/s3/src/Exception/CannotGetFile.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotListBuckets' => $vendorDir . '/akeeba/s3/src/Exception/CannotListBuckets.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotOpenFileForRead' => $vendorDir . '/akeeba/s3/src/Exception/CannotOpenFileForRead.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotOpenFileForWrite' => $vendorDir . '/akeeba/s3/src/Exception/CannotOpenFileForWrite.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotPutFile' => $vendorDir . '/akeeba/s3/src/Exception/CannotPutFile.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\ConfigurationError' => $vendorDir . '/akeeba/s3/src/Exception/ConfigurationError.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidAccessKey' => $vendorDir . '/akeeba/s3/src/Exception/InvalidAccessKey.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidBody' => $vendorDir . '/akeeba/s3/src/Exception/InvalidBody.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidEndpoint' => $vendorDir . '/akeeba/s3/src/Exception/InvalidEndpoint.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidFilePointer' => $vendorDir . '/akeeba/s3/src/Exception/InvalidFilePointer.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidRegion' => $vendorDir . '/akeeba/s3/src/Exception/InvalidRegion.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidSecretKey' => $vendorDir . '/akeeba/s3/src/Exception/InvalidSecretKey.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidSignatureMethod' => $vendorDir . '/akeeba/s3/src/Exception/InvalidSignatureMethod.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\PropertyNotFound' => $vendorDir . '/akeeba/s3/src/Exception/PropertyNotFound.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Input' => $vendorDir . '/akeeba/s3/src/Input.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Request' => $vendorDir . '/akeeba/s3/src/Request.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Response' => $vendorDir . '/akeeba/s3/src/Response.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Response\\Error' => $vendorDir . '/akeeba/s3/src/Response/Error.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Signature' => $vendorDir . '/akeeba/s3/src/Signature.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Signature\\V2' => $vendorDir . '/akeeba/s3/src/Signature/V2.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Signature\\V4' => $vendorDir . '/akeeba/s3/src/Signature/V4.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\StorageClass' => $vendorDir . '/akeeba/s3/src/StorageClass.php',
);

View file

@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);
return array(
);

View file

@ -0,0 +1,10 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);
return array(
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\' => array($vendorDir . '/akeeba/s3/src'),
);

View file

@ -0,0 +1,55 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInitS3StorageAddon
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
spl_autoload_register(array('ComposerAutoloaderInitS3StorageAddon', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader();
spl_autoload_unregister(array('ComposerAutoloaderInitS3StorageAddon', 'loadClassLoader'));
$useStaticLoader = PHP_VERSION_ID >= 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded());
if ($useStaticLoader) {
require_once __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInitS3StorageAddon::getInitializer($loader));
} else {
$map = require __DIR__ . '/autoload_namespaces.php';
foreach ($map as $namespace => $path) {
$loader->set($namespace, $path);
}
$map = require __DIR__ . '/autoload_psr4.php';
foreach ($map as $namespace => $path) {
$loader->setPsr4($namespace, $path);
}
$classMap = require __DIR__ . '/autoload_classmap.php';
if ($classMap) {
$loader->addClassMap($classMap);
}
}
$loader->register(true);
return $loader;
}
}

View file

@ -0,0 +1,62 @@
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInitS3StorageAddon
{
public static $prefixLengthsPsr4 = array (
'A' =>
array (
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\' => 38,
),
);
public static $prefixDirsPsr4 = array (
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\' =>
array (
0 => __DIR__ . '/..' . '/akeeba/s3/src',
),
);
public static $classMap = array (
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Acl' => __DIR__ . '/..' . '/akeeba/s3/src/Acl.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Configuration' => __DIR__ . '/..' . '/akeeba/s3/src/Configuration.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Connector' => __DIR__ . '/..' . '/akeeba/s3/src/Connector.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotDeleteFile' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/CannotDeleteFile.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotGetBucket' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/CannotGetBucket.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotGetFile' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/CannotGetFile.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotListBuckets' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/CannotListBuckets.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotOpenFileForRead' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/CannotOpenFileForRead.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotOpenFileForWrite' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/CannotOpenFileForWrite.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\CannotPutFile' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/CannotPutFile.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\ConfigurationError' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/ConfigurationError.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidAccessKey' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/InvalidAccessKey.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidBody' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/InvalidBody.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidEndpoint' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/InvalidEndpoint.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidFilePointer' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/InvalidFilePointer.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidRegion' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/InvalidRegion.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidSecretKey' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/InvalidSecretKey.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\InvalidSignatureMethod' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/InvalidSignatureMethod.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Exception\\PropertyNotFound' => __DIR__ . '/..' . '/akeeba/s3/src/Exception/PropertyNotFound.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Input' => __DIR__ . '/..' . '/akeeba/s3/src/Input.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Request' => __DIR__ . '/..' . '/akeeba/s3/src/Request.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Response' => __DIR__ . '/..' . '/akeeba/s3/src/Response.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Response\\Error' => __DIR__ . '/..' . '/akeeba/s3/src/Response/Error.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Signature' => __DIR__ . '/..' . '/akeeba/s3/src/Signature.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Signature\\V2' => __DIR__ . '/..' . '/akeeba/s3/src/Signature/V2.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\Signature\\V4' => __DIR__ . '/..' . '/akeeba/s3/src/Signature/V4.php',
'Akeeba\\Engine\\Postproc\\Connector\\S3v4\\StorageClass' => __DIR__ . '/..' . '/akeeba/s3/src/StorageClass.php',
);
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixLengthsPsr4 = ComposerStaticInitS3StorageAddon::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInitS3StorageAddon::$prefixDirsPsr4;
$loader->classMap = ComposerStaticInitS3StorageAddon::$classMap;
}, null, ClassLoader::class);
}
}

View file

@ -0,0 +1,48 @@
[
{
"name": "akeeba/s3",
"version": "2.0.0",
"version_normalized": "2.0.0.0",
"source": {
"type": "git",
"url": "https://github.com/akeeba/s3.git",
"reference": "01520dae1f736555e08efda0ddc1044701bd340a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/akeeba/s3/zipball/01520dae1f736555e08efda0ddc1044701bd340a",
"reference": "01520dae1f736555e08efda0ddc1044701bd340a",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-simplexml": "*",
"php": ">=7.1.0 <8.1"
},
"time": "2020-11-30T14:03:55+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"Akeeba\\Engine\\Postproc\\Connector\\S3v4\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0+"
],
"authors": [
{
"name": "Nicholas K. Dionysopoulos",
"email": "nicholas_NO_SPAM_PLEASE@akeeba.com",
"homepage": "http://www.dionysopoulos.me",
"role": "Lead Developer"
}
],
"description": "A compact, dependency-less Amazon S3 API client implementing the most commonly used features",
"homepage": "https://github.com/akeeba/s3",
"keywords": [
"s3"
]
}
]