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 = "\n"; $part = 0; foreach ($etags as $etag) { $part++; $message .= "\t\n\t\t$part\n\t\t\"$etag\"\n\t\n"; } $message .= ""; // 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; } }