diff --git a/README.md b/README.md index 399ca74..f06cd04 100755 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Streaming uploads to S3 ```php use com\amazon\aws\api\SignatureV4; -use com\amazon\aws\{ServiceEndpoint, CredentialProvider}; +use com\amazon\aws\{ServiceEndpoint, CredentialProvider, S3Key}; use io\File; use util\cmd\Console; @@ -75,7 +75,7 @@ $file= new File('large.txt'); $file->open(File::READ); try { - $transfer= $s3->resource('target/{0}', [$file->filename])->open('PUT', [ + $transfer= $s3->resource(new S3Key('target', $file->filename))->open('PUT', [ 'x-amz-content-sha256' => SignatureV4::UNSIGNED, // Or calculate from file 'Content-Type' => 'text/plain', 'Content-Length' => $file->size(), diff --git a/src/main/php/com/amazon/aws/S3Key.class.php b/src/main/php/com/amazon/aws/S3Key.class.php new file mode 100755 index 0000000..adcbe2e --- /dev/null +++ b/src/main/php/com/amazon/aws/S3Key.class.php @@ -0,0 +1,41 @@ +path= ltrim(implode('/', $components), '/'); + } + + /** Returns the path */ + public function path(string $base= ''): string { + return rtrim($base, '/').'/'.$this->path; + } + + /** @return string */ + public function __toString() { return '/'.$this->path; } + + /** @return string */ + public function hashCode() { return 'S3'.md5($this->path); } + + /** @return string */ + public function toString() { return nameof($this).'(/'.$this->path.')'; } + + /** + * Comparison + * + * @param var $value + * @return int + */ + public function compareTo($value) { + return $value instanceof self ? $this->path <=> $value->path : 1; + } +} \ No newline at end of file diff --git a/src/main/php/com/amazon/aws/ServiceEndpoint.class.php b/src/main/php/com/amazon/aws/ServiceEndpoint.class.php index 14e81b3..23ecc74 100755 --- a/src/main/php/com/amazon/aws/ServiceEndpoint.class.php +++ b/src/main/php/com/amazon/aws/ServiceEndpoint.class.php @@ -128,28 +128,47 @@ public function connecting($connections) { } /** - * Returns a new resource consisting of path including - * optional placeholders and replacement segments. + * Returns a new resource consisting of path including optional placeholders + * and replacement segments. * + * @param string|com.amazon.aws.S3Key $path * @throws lang.ElementNotFoundException */ - public function resource(string $path, array $segments= []): Resource { + public function resource($path, array $segments= []): Resource { return new Resource($this, $path, $segments, $this->marshalling); } + /** + * Extracts path, encoded and params from a given target. Handles S3 keys, which do + * not double-encode the path component in the canonical request. + * + * @param com.amazon.aws.api.SignatureV4 $signature + * @param string|com.amazon.aws.S3Key $target + * @return var[] + */ + private function target($signature, $target) { + if ($target instanceof S3Key) { + $path= $target->path($this->base); + return [$path, $signature->encoded($path), []]; + } else if (false === ($p= strpos($target, '?'))) { + $path= $path= $this->base.ltrim($target, '/'); + return [$path, $path, []]; + } else { + parse_str(substr($target, $p + 1), $params); + $path= $this->base.ltrim(substr($target, 0, $p), '/'); + return [$path, $path, $params]; + } + } + /** Signs a given target (optionally including parameters) with a given expiry time */ - public function sign(string $target, int $expires= 3600, $time= null): string { + public function sign($target, int $expires= 3600, $time= null): string { $signature= new SignatureV4($this->credentials()); + list($path, $encoded, $params)= $this->target($signature, $target); + $host= $this->domain(); $region= $this->region ?? '*'; // Combine target parameters with `X-Amz-*` headers used for signature - if (false === ($p= strpos($target, '?'))) { - $params= []; - } else { - parse_str(substr($target, $p + 1), $params); - $target= substr($target, 0, $p); - } $params+= [ 'X-Amz-Algorithm' => SignatureV4::ALGO, 'X-Amz-Credential' => $signature->credential($this->service, $region, $time), @@ -161,12 +180,11 @@ public function sign(string $target, int $expires= 3600, $time= null): string { // Next, sign path and query string with the special hash `UNSIGNED-PAYLOAD`, // signing only the "Host" header as indicated above. - $link= $this->base.ltrim($target, '/'); $signature= $signature->sign( $this->service, $region, 'GET', - $link, + $path, $params, SignatureV4::UNSIGNED, ['Host' => $host], @@ -175,7 +193,7 @@ public function sign(string $target, int $expires= 3600, $time= null): string { // Finally, append signature parameter to signed link $params['X-Amz-Signature']= $signature['signature']; - return "https://{$host}{$link}?".http_build_query($params, '', '&', PHP_QUERY_RFC3986); + return "https://{$host}{$encoded}?".http_build_query($params, '', '&', PHP_QUERY_RFC3986); } /** @@ -183,25 +201,18 @@ public function sign(string $target, int $expires= 3600, $time= null): string { * * @throws io.IOException */ - public function open(string $method, string $target, array $headers, $hash= null, $time= null): Transfer { + public function open(string $method, $target, array $headers, $hash= null, $time= null): Transfer { $signature= new SignatureV4($this->credentials()); + list($path, $encoded, $params)= $this->target($signature, $target); + $host= $this->domain(); - $target= $this->base.ltrim($target, '/'); - $conn= ($this->connections)('https://'.$host.$target); + $conn= ($this->connections)('https://'.$host.$encoded); $conn->setTrace($this->cat); - // Parse and separate query string parameters - if (false === ($p= strpos($target, '?'))) { - $params= []; - } else { - parse_str(substr($target, $p + 1), $params); - $target= substr($target, 0, $p); - } - // Create and sign request $request= $conn->create(new HttpRequest()); $request->setMethod($method); - $request->setTarget(strtr(rawurlencode($target), ['%2F' => '/'])); + $request->setTarget($encoded); $request->addHeaders($headers); // Compile headers from given host and time including our user agent @@ -228,7 +239,7 @@ public function open(string $method, string $target, array $headers, $hash= null $this->service, $this->region ?? '*', $method, - $target, + $path, $params, $hash ?? $headers['x-amz-content-sha256'] ?? SignatureV4::NO_PAYLOAD, $signed, @@ -250,7 +261,7 @@ public function open(string $method, string $target, array $headers, $hash= null * * @throws io.IOException */ - public function request(string $method, string $target, array $headers= [], $payload= null, $time= null): Response { + public function request(string $method, $target, array $headers= [], $payload= null, $time= null): Response { if (null === $payload) { $transfer= $this->open($method, $target, $headers + ['Content-Length' => 0], SignatureV4::NO_PAYLOAD, $time); } else { diff --git a/src/main/php/com/amazon/aws/api/Resource.class.php b/src/main/php/com/amazon/aws/api/Resource.class.php index 6934ab3..d02745b 100755 --- a/src/main/php/com/amazon/aws/api/Resource.class.php +++ b/src/main/php/com/amazon/aws/api/Resource.class.php @@ -1,5 +1,6 @@ endpoint= $endpoint; $this->marshalling= $marshalling ?? new Marshalling(); - $l= strlen($path); - $offset= 0; - do { - $b= strcspn($path, '{', $offset); - $this->target.= substr($path, $offset, $b); - $offset+= $b; - if ($offset >= $l) break; + if ($path instanceof S3Key) { + $this->target= $path; + } else { + $this->target= ''; + $l= strlen($path); + $offset= 0; + do { + $b= strcspn($path, '{', $offset); + $this->target.= substr($path, $offset, $b); + $offset+= $b; + if ($offset >= $l) break; - $e= strcspn($path, '}', $offset); - $name= substr($path, $offset + 1, $e - 1); - if (null === ($segment= $segments[$name] ?? null)) { - throw new ElementNotFoundException('No such segment "'.$name.'"'); - } + $e= strcspn($path, '}', $offset); + $name= substr($path, $offset + 1, $e - 1); + if (null === ($segment= $segments[$name] ?? null)) { + throw new ElementNotFoundException('No such segment "'.$name.'"'); + } - $this->target.= $segment; - $offset+= $e + 1; - } while ($offset < $l); + $this->target.= rawurlencode($segment); + $offset+= $e + 1; + } while ($offset < $l); + } } /** diff --git a/src/main/php/com/amazon/aws/api/SignatureV4.class.php b/src/main/php/com/amazon/aws/api/SignatureV4.class.php index 5392c17..e631296 100755 --- a/src/main/php/com/amazon/aws/api/SignatureV4.class.php +++ b/src/main/php/com/amazon/aws/api/SignatureV4.class.php @@ -44,6 +44,11 @@ public function securityToken() { return $this->credentials->sessionToken(); } + /** URI-encode a given path */ + public function encoded(string $path): string { + return strtr(rawurlencode($path), ['%2F' => '/']); + } + /** Returns a signature */ public function sign( string $service, @@ -57,14 +62,14 @@ public function sign( ): array { $requestDate= $this->datetime($time); - // Create a canonical request using the URI-encoded version of the path + // Step 1: Create a canonical request using the URI-encoded version of the path if ($params) { ksort($params); $query= http_build_query($params, '', '&', PHP_QUERY_RFC3986); } else { $query= ''; } - $canonical= "{$method}\n".strtr(rawurlencode($target), ['%2F' => '/'])."\n{$query}\n"; + $canonical= "{$method}\n{$this->encoded($target)}\n{$query}\n"; // Header names must use lowercase characters and must appear in alphabetical order. $sorted= []; diff --git a/src/test/php/com/amazon/aws/unittest/RequestTest.class.php b/src/test/php/com/amazon/aws/unittest/RequestTest.class.php index 7415ab9..f4b1d98 100644 --- a/src/test/php/com/amazon/aws/unittest/RequestTest.class.php +++ b/src/test/php/com/amazon/aws/unittest/RequestTest.class.php @@ -1,8 +1,8 @@ content()); } - #[Test] - public function transfer_via_resource_path_and_segments() { + #[Test, Values(['with space.png', 'ümläut.png', 'encoded+char.png'])] + public function transfer_via_s3key_resource($filename) { $file= 'PNG...'; $s3= $this->endpoint('s3', [ - '/target/upload%20file.png' => [ + '/target/'.rawurlencode($filename) => [ 'HTTP/1.1 200 OK', 'Content-Length: 0', '', ] ]); - $transfer= $s3->resource('/target/{0}', ['upload file.png'])->open('PUT', [ + $transfer= $s3->resource(new S3Key('target', $filename))->open('PUT', [ 'x-amz-content-sha256' => hash('sha256', $file), 'Content-Type' => 'image/png', 'Content-Length' => strlen($file), diff --git a/src/test/php/com/amazon/aws/unittest/S3KeyTest.class.php b/src/test/php/com/amazon/aws/unittest/S3KeyTest.class.php new file mode 100755 index 0000000..f3b92e7 --- /dev/null +++ b/src/test/php/com/amazon/aws/unittest/S3KeyTest.class.php @@ -0,0 +1,46 @@ +path()); + } + + #[Test] + public function single() { + Assert::equals('/test', (new S3Key('test'))->path()); + } + + #[Test] + public function composed() { + Assert::equals('/target/test', (new S3Key('target', 'test'))->path()); + } + + #[Test, Values(['/base', '/base/'])] + public function based($base) { + Assert::equals('/base/test', (new S3Key('test'))->path($base)); + } + + #[Test] + public function string_cast() { + Assert::equals('/test', (string)new S3Key('test')); + } + + #[Test] + public function string_representation() { + Assert::equals('com.amazon.aws.S3Key(/target/test)', (new S3Key('target', 'test'))->toString()); + } + + #[Test] + public function comparison() { + $fixture= new S3Key('b-test'); + + Assert::equals(0, $fixture->compareTo(new S3Key('b-test'))); + Assert::equals(1, $fixture->compareTo(new S3Key('a-test'))); + Assert::equals(-1, $fixture->compareTo(new S3Key('c-test'))); + } +} \ No newline at end of file diff --git a/src/test/php/com/amazon/aws/unittest/ServiceEndpointTest.class.php b/src/test/php/com/amazon/aws/unittest/ServiceEndpointTest.class.php index 831068b..3d5e0e2 100644 --- a/src/test/php/com/amazon/aws/unittest/ServiceEndpointTest.class.php +++ b/src/test/php/com/amazon/aws/unittest/ServiceEndpointTest.class.php @@ -2,7 +2,7 @@ use com\amazon\aws\api\Resource; use com\amazon\aws\credentials\FromGiven; -use com\amazon\aws\{ServiceEndpoint, Credentials, CredentialProvider}; +use com\amazon\aws\{ServiceEndpoint, Credentials, CredentialProvider, S3Key}; use lang\IllegalArgumentException; use test\{Assert, Before, Expect, Test, Values}; @@ -150,4 +150,24 @@ public function sign_link_with_param() { $uri ); } + + #[Test] + public function sign_link_with_s3key() { + $uri= (new ServiceEndpoint('s3', $this->credentials)) + ->using('bucket') + ->sign(new S3Key('folder', 'über test.txt'), 3600, strtotime('20230314T231444Z')) + ; + + Assert::equals( + 'https://bucket.s3.amazonaws.com/folder/%C3%BCber%20test.txt'. + '?X-Amz-Algorithm=AWS4-HMAC-SHA256'. + '&X-Amz-Credential=key%2F20230314%2F%2A%2Fs3%2Faws4_request'. + '&X-Amz-Date=20230314T231444Z'. + '&X-Amz-Expires=3600'. + '&X-Amz-Security-Token=session'. + '&X-Amz-SignedHeaders=host'. + '&X-Amz-Signature=529b41315ab05acbeb3c594181de0c65657ec67c4d013a7c78c3977a1ac180b9', + $uri + ); + } } \ No newline at end of file