forked from friendica/friendica-addons
761 lines
17 KiB
PHP
761 lines
17 KiB
PHP
<?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;
|
|
}
|
|
}
|