friendica-addons/s3_storage/vendor/akeeba/s3/src/Request.php
2022-02-25 08:46:14 +01:00

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