diff --git a/README.md b/README.md index 6643f90b..636005bd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Statically (e,g; S3::getObject(...)): S3::setAuth($awsAccessKey, $awsSecretKey); ``` + ### Object Operations #### Uploading objects diff --git a/S3.php b/S3.php index 0b1564b2..076b4ad3 100644 --- a/S3.php +++ b/S3.php @@ -32,7 +32,7 @@ * Amazon S3 PHP class * * @link http://undesigned.org.za/2007/10/22/amazon-s3-php-class -* @version 0.5.1-dev +* @version 0.5.1 */ class S3 { @@ -44,6 +44,7 @@ class S3 const STORAGE_CLASS_STANDARD = 'STANDARD'; const STORAGE_CLASS_RRS = 'REDUCED_REDUNDANCY'; + const STORAGE_CLASS_STANDARD_IA = 'STANDARD_IA'; const SSE_NONE = ''; const SSE_AES256 = 'AES256'; @@ -56,7 +57,7 @@ class S3 * @static */ private static $__accessKey = null; - + /** * AWS Secret Key * @@ -65,7 +66,7 @@ class S3 * @static */ private static $__secretKey = null; - + /** * SSL Client key * @@ -74,7 +75,15 @@ class S3 * @static */ private static $__sslKey = null; - + + /** + * Default delimiter to be used, for example while getBucket(). + * @var string + * @access public + * @static + */ + public static $defDelimiter = null; + /** * AWS URI * @@ -83,7 +92,16 @@ class S3 * @static */ public static $endpoint = 's3.amazonaws.com'; - + + /** + * AWS Region + * + * @var string + * @acess public + * @static + */ + public static $region = ''; + /** * Proxy information * @@ -92,7 +110,7 @@ class S3 * @static */ public static $proxy = null; - + /** * Connect using SSL? * @@ -101,7 +119,7 @@ class S3 * @static */ public static $useSSL = false; - + /** * Use SSL validation? * @@ -110,7 +128,16 @@ class S3 * @static */ public static $useSSLValidation = true; - + + /** + * Use SSL version + * + * @var const + * @access public + * @static + */ + public static $useSSLVersion = CURL_SSLVERSION_TLSv1; + /** * Use PHP exceptions? * @@ -172,6 +199,14 @@ class S3 */ private static $__signingKeyResource = false; + /** + * CURL progress function callback + * + * @var function + * @access public + * @static + */ + public static $progressFunction = null; /** * Constructor - if you're not using the class statically @@ -182,12 +217,13 @@ class S3 * @param string $endpoint Amazon URI * @return void */ - public function __construct($accessKey = null, $secretKey = null, $useSSL = false, $endpoint = 's3.amazonaws.com') + public function __construct($accessKey = null, $secretKey = null, $useSSL = false, $endpoint = 's3.amazonaws.com', $region = '') { if ($accessKey !== null && $secretKey !== null) self::setAuth($accessKey, $secretKey); self::$useSSL = $useSSL; self::$endpoint = $endpoint; + self::$region = $region; } @@ -202,6 +238,43 @@ public function setEndpoint($host) self::$endpoint = $host; } + + /** + * Set the service region + * + * @param string $region + * @return void + */ + public function setRegion($region) + { + self::$region = $region; + } + + + /** + * Get the service region + * + * @return string $region + * @static + */ + public static function getRegion() + { + $region = self::$region; + + // parse region from endpoint if not specific + if (empty($region)) + { + if (preg_match("/s3[.-](?:website-|dualstack\.)?(.+)\.amazonaws\.com/i", self::$endpoint, $match) !== 0 + && strtolower($match[1]) !== "external-1") + { + $region = $match[1]; + } + } + + return empty($region) ? 'us-east-1' : $region; + } + + /** * Set AWS access key and secret key * @@ -325,6 +398,7 @@ public static function setSigningKey($keyPairId, $signingKey, $isFile = true) } + /** * Free signing key from memory, MUST be called if you are using setSigningKey() * @@ -336,6 +410,17 @@ public static function freeSigningKey() openssl_free_key(self::$__signingKeyResource); } + /** + * Set progress function + * + * @param function $func Progress function + * @return void + */ + public static function setProgressFunction($func = null) + { + self::$progressFunction = $func; + } + /** * Internal error handler @@ -416,6 +501,7 @@ public static function getBucket($bucket, $prefix = null, $marker = null, $maxKe if ($marker !== null && $marker !== '') $rest->setParameter('marker', $marker); if ($maxKeys !== null && $maxKeys !== '') $rest->setParameter('max-keys', $maxKeys); if ($delimiter !== null && $delimiter !== '') $rest->setParameter('delimiter', $delimiter); + else if (!empty(self::$defDelimiter)) $rest->setParameter('delimiter', self::$defDelimiter); $response = $rest->getResponse(); if ($response->error === false && $response->code !== 200) $response->error = array('code' => $response->code, 'message' => 'Unexpected HTTP status'); @@ -500,7 +586,9 @@ public static function putBucket($bucket, $acl = self::ACL_PRIVATE, $location = $rest = new S3Request('PUT', $bucket, '', self::$endpoint); $rest->setAmzHeader('x-amz-acl', $acl); - if ($location !== false) + if ($location === false) $location = self::getRegion(); + + if ($location !== false && $location !== "us-east-1") { $dom = new DOMDocument; $createBucketConfiguration = $dom->createElement('CreateBucketConfiguration'); @@ -561,8 +649,9 @@ public static function inputFile($file, $md5sum = true) self::__triggerError('S3::inputFile(): Unable to open input file: '.$file, __FILE__, __LINE__); return false; } + clearstatcache(false, $file); return array('file' => $file, 'size' => filesize($file), 'md5sum' => $md5sum !== false ? - (is_string($md5sum) ? $md5sum : base64_encode(md5_file($file, true))) : ''); + (is_string($md5sum) ? $md5sum : base64_encode(md5_file($file, true))) : '', 'sha256sum' => hash_file('sha256', $file)); } @@ -619,7 +708,8 @@ public static function putObject($input, $bucket, $uri, $acl = self::ACL_PRIVATE if (!is_array($input)) $input = array( 'data' => $input, 'size' => strlen($input), - 'md5sum' => base64_encode(md5($input, true)) + 'md5sum' => base64_encode(md5($input, true)), + 'sha256sum' => hash('sha256', $input) ); // Data @@ -634,15 +724,18 @@ public static function putObject($input, $bucket, $uri, $acl = self::ACL_PRIVATE if (isset($input['size']) && $input['size'] >= 0) $rest->size = $input['size']; else { - if (isset($input['file'])) + if (isset($input['file'])) { + clearstatcache(false, $input['file']); $rest->size = filesize($input['file']); + } elseif (isset($input['data'])) $rest->size = strlen($input['data']); } // Custom request headers (Content-Type, Content-Disposition, Content-Encoding) if (is_array($requestHeaders)) - foreach ($requestHeaders as $h => $v) $rest->setHeader($h, $v); + foreach ($requestHeaders as $h => $v) + strpos($h, 'x-amz-') === 0 ? $rest->setAmzHeader($h, $v) : $rest->setHeader($h, $v); elseif (is_string($requestHeaders)) // Support for legacy contentType parameter $input['type'] = $requestHeaders; @@ -669,6 +762,8 @@ public static function putObject($input, $bucket, $uri, $acl = self::ACL_PRIVATE $rest->setHeader('Content-Type', $input['type']); if (isset($input['md5sum'])) $rest->setHeader('Content-MD5', $input['md5sum']); + if (isset($input['sha256sum'])) $rest->setAmzHeader('x-amz-content-sha256', $input['sha256sum']); + $rest->setAmzHeader('x-amz-acl', $acl); foreach ($metaHeaders as $h => $v) $rest->setAmzHeader('x-amz-meta-'.$h, $v); $rest->getResponse(); @@ -797,7 +892,8 @@ public static function copyObject($srcBucket, $srcUri, $bucket, $uri, $acl = sel { $rest = new S3Request('PUT', $bucket, $uri, self::$endpoint); $rest->setHeader('Content-Length', 0); - foreach ($requestHeaders as $h => $v) $rest->setHeader($h, $v); + foreach ($requestHeaders as $h => $v) + strpos($h, 'x-amz-') === 0 ? $rest->setAmzHeader($h, $v) : $rest->setHeader($h, $v); foreach ($metaHeaders as $h => $v) $rest->setAmzHeader('x-amz-meta-'.$h, $v); if ($storageClass !== self::STORAGE_CLASS_STANDARD) // Storage class $rest->setAmzHeader('x-amz-storage-class', $storageClass); @@ -1683,11 +1779,13 @@ private static function __getCloudFrontDistributionConfigXML($bucket, $enabled, if ($comment !== '') $distributionConfig->appendChild($dom->createElement('Comment', $comment)); $distributionConfig->appendChild($dom->createElement('Enabled', $enabled ? 'true' : 'false')); - $trusted = $dom->createElement('TrustedSigners'); - foreach ($trustedSigners as $id => $type) - $trusted->appendChild($id !== '' ? $dom->createElement($type, $id) : $dom->createElement($type)); - $distributionConfig->appendChild($trusted); - + if (!empty($trustedSigners)) + { + $trusted = $dom->createElement('TrustedSigners'); + foreach ($trustedSigners as $id => $type) + $trusted->appendChild($id !== '' ? $dom->createElement($type, $id) : $dom->createElement($type)); + $distributionConfig->appendChild($trusted); + } $dom->appendChild($distributionConfig); //var_dump($dom->saveXML()); return $dom->saveXML(); @@ -1882,6 +1980,103 @@ private static function __getHash($string) (str_repeat(chr(0x36), 64))) . $string))))); } + + /** + * Generate the headers for AWS Signature V4 + * + * @internal Used by S3Request::getResponse() + * @param array $amzHeaders + * @param array $headers + * @param string $method + * @param string $uri + * @param array $parameters + * @return array + */ + public static function __getSignatureV4($amzHeaders, $headers, $method, $uri, $parameters) + { + $service = 's3'; + $region = S3::getRegion(); + + $algorithm = 'AWS4-HMAC-SHA256'; + $combinedHeaders = array(); + + $amzDateStamp = substr($amzHeaders['x-amz-date'], 0, 8); + + // CanonicalHeaders + foreach ($headers as $k => $v) + $combinedHeaders[strtolower($k)] = trim($v); + foreach ($amzHeaders as $k => $v) + $combinedHeaders[strtolower($k)] = trim($v); + uksort($combinedHeaders, array(self::class, '__sortMetaHeadersCmp')); + + // Convert null query string parameters to strings and sort + $parameters = array_map('strval', $parameters); + uksort($parameters, array(self::class, '__sortMetaHeadersCmp')); + $queryString = http_build_query($parameters, null, '&', PHP_QUERY_RFC3986); + + // Payload + $amzPayload = array($method); + + $qsPos = strpos($uri, '?'); + $amzPayload[] = ($qsPos === false ? $uri : substr($uri, 0, $qsPos)); + + $amzPayload[] = $queryString; + // add header as string to requests + foreach ($combinedHeaders as $k => $v ) + { + $amzPayload[] = $k . ':' . $v; + } + // add a blank entry so we end up with an extra line break + $amzPayload[] = ''; + // SignedHeaders + $amzPayload[] = implode(';', array_keys($combinedHeaders)); + // payload hash + $amzPayload[] = $amzHeaders['x-amz-content-sha256']; + // request as string + $amzPayloadStr = implode("\n", $amzPayload); + + // CredentialScope + $credentialScope = array($amzDateStamp, $region, $service, 'aws4_request'); + + // stringToSign + $stringToSignStr = implode("\n", array($algorithm, $amzHeaders['x-amz-date'], + implode('/', $credentialScope), hash('sha256', $amzPayloadStr))); + + // Make Signature + $kSecret = 'AWS4' . self::$__secretKey; + $kDate = hash_hmac('sha256', $amzDateStamp, $kSecret, true); + $kRegion = hash_hmac('sha256', $region, $kDate, true); + $kService = hash_hmac('sha256', $service, $kRegion, true); + $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); + + $signature = hash_hmac('sha256', $stringToSignStr, $kSigning); + + return $algorithm . ' ' . implode(',', array( + 'Credential=' . self::$__accessKey . '/' . implode('/', $credentialScope), + 'SignedHeaders=' . implode(';', array_keys($combinedHeaders)), + 'Signature=' . $signature, + )); + } + + + /** + * Sort compare for meta headers + * + * @internal Used to sort x-amz meta headers + * @param string $a String A + * @param string $b String B + * @return integer + */ + private static function __sortMetaHeadersCmp($a, $b) + { + $lenA = strlen($a); + $lenB = strlen($b); + $minLen = min($lenA, $lenB); + $ncmp = strncmp($a, $b, $minLen); + if ($lenA == $lenB) return $ncmp; + if (0 == $ncmp) return $lenA < $lenB ? -1 : 1; + return $ncmp; + } } /** @@ -1896,7 +2091,7 @@ final class S3Request * AWS URI * * @var string - * @access pricate + * @access private */ private $endpoint; @@ -2002,17 +2197,11 @@ final class S3Request */ function __construct($verb, $bucket = '', $uri = '', $endpoint = 's3.amazonaws.com') { - $this->endpoint = $endpoint; $this->verb = $verb; $this->bucket = $bucket; $this->uri = $uri !== '' ? '/'.str_replace('%2F', '/', rawurlencode($uri)) : '/'; - //if ($this->bucket !== '') - // $this->resource = '/'.$this->bucket.$this->uri; - //else - // $this->resource = $this->uri; - if ($this->bucket !== '') { if ($this->__dnsBucketName($this->bucket)) @@ -2022,6 +2211,7 @@ function __construct($verb, $bucket = '', $uri = '', $endpoint = 's3.amazonaws.c } else { + // Old format, deprecated by AWS - removal scheduled for September 30th, 2020 $this->headers['Host'] = $this->endpoint; $this->uri = $this->uri; if ($this->bucket !== '') $this->uri = '/'.$this->bucket.$this->uri; @@ -2109,14 +2299,15 @@ public function getResponse() } $url = (S3::$useSSL ? 'https://' : 'http://') . ($this->headers['Host'] !== '' ? $this->headers['Host'] : $this->endpoint) . $this->uri; - //var_dump('bucket: ' . $this->bucket, 'uri: ' . $this->uri, 'resource: ' . $this->resource, 'url: ' . $url); - // Basic setup $curl = curl_init(); curl_setopt($curl, CURLOPT_USERAGENT, 'S3/php'); if (S3::$useSSL) { + // Set protocol version + curl_setopt($curl, CURLOPT_SSLVERSION, S3::$useSSLVersion); + // SSL Validation can now be optional for those with broken OpenSSL installations curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, S3::$useSSLValidation ? 2 : 0); curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, S3::$useSSLValidation ? 1 : 0); @@ -2137,42 +2328,46 @@ public function getResponse() } // Headers - $headers = array(); $amz = array(); - 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; - - // Collect AMZ headers for signature - foreach ($this->amzHeaders as $header => $value) - if (strlen($value) > 0) $amz[] = strtolower($header).':'.$value; - - // AMZ headers must be sorted - if (sizeof($amz) > 0) - { - //sort($amz); - usort($amz, array(&$this, '__sortMetaHeadersCmp')); - $amz = "\n".implode("\n", $amz); - } else $amz = ''; - + $httpHeaders = array(); if (S3::hasAuth()) { // Authorization string (CloudFront stringToSign should only contain a date) if ($this->headers['Host'] == 'cloudfront.amazonaws.com') - $headers[] = 'Authorization: ' . S3::__getSignature($this->headers['Date']); + { + # TODO: Update CloudFront authentication + foreach ($this->amzHeaders as $header => $value) + if (strlen($value) > 0) $httpHeaders[] = $header.': '.$value; + + foreach ($this->headers as $header => $value) + if (strlen($value) > 0) $httpHeaders[] = $header.': '.$value; + + $httpHeaders[] = 'Authorization: ' . S3::__getSignature($this->headers['Date']); + } else { - $headers[] = 'Authorization: ' . S3::__getSignature( - $this->verb."\n". - $this->headers['Content-MD5']."\n". - $this->headers['Content-Type']."\n". - $this->headers['Date'].$amz."\n". - $this->resource + $this->amzHeaders['x-amz-date'] = gmdate('Ymd\THis\Z'); + + if (!isset($this->amzHeaders['x-amz-content-sha256'])) + $this->amzHeaders['x-amz-content-sha256'] = hash('sha256', $this->data); + + foreach ($this->amzHeaders as $header => $value) + if (strlen($value) > 0) $httpHeaders[] = $header.': '.$value; + + foreach ($this->headers as $header => $value) + if (strlen($value) > 0) $httpHeaders[] = $header.': '.$value; + + $httpHeaders[] = 'Authorization: ' . S3::__getSignatureV4( + $this->amzHeaders, + $this->headers, + $this->verb, + $this->uri, + $this->parameters ); + } } - curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + curl_setopt($curl, CURLOPT_HTTPHEADER, $httpHeaders); curl_setopt($curl, CURLOPT_HEADER, false); curl_setopt($curl, CURLOPT_RETURNTRANSFER, false); curl_setopt($curl, CURLOPT_WRITEFUNCTION, array(&$this, '__responseWriteCallback')); @@ -2209,6 +2404,12 @@ public function getResponse() default: break; } + // set curl progress function callback + if (S3::$progressFunction) { + curl_setopt($curl, CURLOPT_NOPROGRESS, false); + curl_setopt($curl, CURLOPT_PROGRESSFUNCTION, S3::$progressFunction); + } + // Execute, grab errors if (curl_exec($curl)) $this->response->code = curl_getinfo($curl, CURLINFO_HTTP_CODE); @@ -2247,24 +2448,6 @@ public function getResponse() return $this->response; } - /** - * Sort compare for meta headers - * - * @internal Used to sort x-amz meta headers - * @param string $a String A - * @param string $b String B - * @return integer - */ - private function __sortMetaHeadersCmp($a, $b) - { - $lenA = strpos($a, ':'); - $lenB = strpos($b, ':'); - $minLen = min($lenA, $lenB); - $ncmp = strncmp($a, $b, $minLen); - if ($lenA == $lenB) return $ncmp; - if (0 == $ncmp) return $lenA < $lenB ? -1 : 1; - return $ncmp; - } /** * CURL write callback @@ -2292,6 +2475,7 @@ private function __responseWriteCallback(&$curl, &$data) private function __dnsBucketName($bucket) { if (strlen($bucket) > 63 || preg_match("/[^a-z0-9\.-]/", $bucket) > 0) return false; + if (S3::$useSSL && strstr($bucket, '.') !== false) return false; if (strstr($bucket, '-.') !== false) return false; if (strstr($bucket, '..') !== false) return false; if (!preg_match("/^[0-9a-z]/", $bucket)) return false; @@ -2317,16 +2501,17 @@ private function __responseHeaderCallback($curl, $data) $data = trim($data); if (strpos($data, ': ') === false) return $strlen; list($header, $value) = explode(': ', $data, 2); - if ($header == 'Last-Modified') + $header = strtolower($header); + if ($header == 'last-modified') $this->response->headers['time'] = strtotime($value); - elseif ($header == 'Date') + elseif ($header == 'date') $this->response->headers['date'] = strtotime($value); - elseif ($header == 'Content-Length') + elseif ($header == 'content-length') $this->response->headers['size'] = (int)$value; - elseif ($header == 'Content-Type') + elseif ($header == 'content-type') $this->response->headers['type'] = $value; - elseif ($header == 'ETag') - $this->response->headers['hash'] = $value{0} == '"' ? substr($value, 1, -1) : $value; + elseif ($header == 'etag') + $this->response->headers['hash'] = $value[0] == '"' ? substr($value, 1, -1) : $value; elseif (preg_match('/^x-amz-meta-.*$/', $header)) $this->response->headers[$header] = $value; }