diff --git a/src/main/php/web/Headers.class.php b/src/main/php/web/Headers.class.php index 3c492ec..a7162e4 100755 --- a/src/main/php/web/Headers.class.php +++ b/src/main/php/web/Headers.class.php @@ -11,6 +11,7 @@ * @test web.unittest.HeadersTest */ abstract class Headers { + const ATTR_CHAR= 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_'; /** * Formats a date for use in headers @@ -162,6 +163,23 @@ protected function next($input, &$offset) { // consistency with PHP, see https://github.com/php/php-src/issues/8206 $parameters[$name]= strtr(substr($input, $offset + 1, $p - $offset - 2), ['\"' => '"']); $offset= $p + 1; + } else if ('*' === $name[strlen($name) - 1]) { + + // RFC 8187: Character Encoding and Language for HTTP Header Field Parameters + $s= strcspn($input, "'", $offset); + $charset= substr($input, $offset, $s); + $offset+= $s + 1; + + $s= strcspn($input, "'", $offset); + $lang= substr($input, $offset, $s); + $offset+= $s + 1; + + $s= strcspn($input, ',;', $offset); + $parameters[$name]= [ + 'lang' => $lang ?: null, + 'value' => iconv($charset, \xp::ENCODING, urldecode(substr($input, $offset, $s))) + ]; + $offset+= $s + 1; } else { $s= strcspn($input, ',;', $offset); $parameters[$name]= substr($input, $offset, $s); diff --git a/src/main/php/web/Parameterized.class.php b/src/main/php/web/Parameterized.class.php index 149e8d6..9f261f3 100755 --- a/src/main/php/web/Parameterized.class.php +++ b/src/main/php/web/Parameterized.class.php @@ -7,28 +7,86 @@ class Parameterized { * Creates a new instance * * @param string $value - * @param [:string] $params + * @param [:var] $params */ - public function __construct($value, array $params) { + public function __construct($value, array $params= []) { $this->value= $value; $this->params= $params; } + /** + * Passes parameters and optionally, their ASCII equivalents. + * + * @param string $name + * @param string|[:string] $value + * @param ?string $equivalent + * @return self + */ + public function with($name, $value, $equivalent= null) { + $name= rtrim($name, '*'); + null === $equivalent || $this->params[$name]= $equivalent; + + if (is_array($value)) { + $this->params[$name.'*']= 1 === sizeof($value) + ? ['lang' => current($value), 'value' => key($value)] + : $value + ; + } else if (isset($equivalent) || preg_match('/[\x7f-\xff]/', $value)) { + $this->params[$name.'*']= ['lang' => null, 'value' => $value]; + } else { + $this->params[$name]= $value; + } + + return $this; + } + /** @return string */ public function value() { return $this->value; } - /** @return [:string] */ + /** @return [:var] */ public function params() { return $this->params; } /** - * Gets a parameter by its name, returning a default value if it's - * not present. + * Gets a parameter by its name, returning a default value if it's not + * present. Prefers the RFC 8187 encoded parameter ending with `*`. * * @param string $name * @param var $default * @return var */ public function param($name, $default= null) { + if ($param= $this->params[$name.'*'] ?? null) { + return $param['value']; + } else { + return $this->params[$name] ?? $default; + } + } + + /** + * Gets a parameter equivalent by its name, returning a default value + * if it's not present. + * + * @param string $name + * @param var $default + * @return var + */ + public function equivalent($name, $default= null) { return $this->params[$name] ?? $default; } + + /** @return string */ + public function __toString() { + $s= $this->value; + foreach ($this->params as $name => $value) { + $s.= '; '.$name; + if (is_array($value)) { + $s.= "=UTF-8'{$value['lang']}'".rawurlencode($value['value']); + } else if (strspn($value, Headers::ATTR_CHAR) < strlen($value)) { + $s.= '="'.strtr($value, ['\\' => '\\\\', '"' => '\\"']).'"'; + } else { + $s.= '='.$value; + } + } + return $s; + } } \ No newline at end of file diff --git a/src/test/php/web/unittest/HeadersTest.class.php b/src/test/php/web/unittest/HeadersTest.class.php index ddfa04e..e838338 100755 --- a/src/test/php/web/unittest/HeadersTest.class.php +++ b/src/test/php/web/unittest/HeadersTest.class.php @@ -34,6 +34,38 @@ public function content_disposition($header) { ); } + #[Test] + public function rfc8187_encoding() { + $parameterized= Headers::parameterized()->parse("attachment; filename*=UTF-8''%C3%BCber%20name.jpg"); + + Assert::equals('attachment', $parameterized->value()); + Assert::equals(['filename*' => ['lang' => null, 'value' => 'über name.jpg']], $parameterized->params()); + Assert::equals('über name.jpg', $parameterized->param('filename')); + Assert::null($parameterized->equivalent('filename')); + } + + #[Test] + public function rfc8187_encoding_and_language() { + $parameterized= Headers::parameterized()->parse("attachment; filename*=UTF-8'en'file%20name.jpg"); + + Assert::equals('attachment', $parameterized->value()); + Assert::equals(['filename*' => ['lang' => 'en', 'value' => 'file name.jpg']], $parameterized->params()); + Assert::equals('file name.jpg', $parameterized->param('filename')); + Assert::null($parameterized->equivalent('filename')); + } + + #[Test] + public function rfc8187_encoded_takes_precedence() { + $parameterized= Headers::parameterized()->parse("attachment; filename=\"ascii.jpg\"; filename*=UTF-8''unicode.jpg"); + + Assert::equals( + ['filename' => 'ascii.jpg', 'filename*' => ['lang' => null, 'value' => 'unicode.jpg']], + $parameterized->params() + ); + Assert::equals('unicode.jpg', $parameterized->param('filename')); + Assert::equals('ascii.jpg', $parameterized->equivalent('filename')); + } + #[Test, Values(['5;url=http://www.w3.org/pub/WWW/People.html', '5; url=http://www.w3.org/pub/WWW/People.html',])] public function refresh($header) { Assert::equals( diff --git a/src/test/php/web/unittest/ResponseTest.class.php b/src/test/php/web/unittest/ResponseTest.class.php index 13b9b4d..17137cc 100755 --- a/src/test/php/web/unittest/ResponseTest.class.php +++ b/src/test/php/web/unittest/ResponseTest.class.php @@ -6,19 +6,42 @@ use test\{Assert, Expect, Test, Values}; use util\URI; use web\io\{Buffered, TestOutput}; -use web\{Cookie, Response}; +use web\{Cookie, Response, Parameterized}; class ResponseTest { - /** - * Assertion helper - * - * @param string $expected - * @param web.Response $response - * @throws unittest.AssertionFailedError - */ - private function assertResponse($expected, $response) { - Assert::equals($expected, $response->output()->bytes()); + /** @return iterable */ + private function answers() { + + // Supplied as-is + yield [200, 'OK', 'HTTP/1.1 200 OK']; + yield [404, 'Not Found', 'HTTP/1.1 404 Not Found']; + + // Status message derived from code + yield [200, null, 'HTTP/1.1 200 OK']; + yield [404, null, 'HTTP/1.1 404 Not Found']; + + // Status message overridden + yield [200, 'Okay', 'HTTP/1.1 200 Okay']; + yield [404, 'Nope', 'HTTP/1.1 404 Nope']; + } + + /** @return iterable */ + private function filenames() { + + // Simple file names + yield ['test.txt', 'filename=test.txt']; + yield ['a b.txt', 'filename="a b.txt"']; + yield ['"hello".txt', 'filename="\"hello\".txt"']; + yield ['über.txt', "filename*=UTF-8''%C3%BCber.txt"]; + + // In the form of [name => lang] + yield [['test.txt' => null], "filename*=UTF-8''test.txt"]; + yield [['über.txt' => null], "filename*=UTF-8''%C3%BCber.txt"]; + yield [['über.txt' => 'de'], "filename*=UTF-8'de'%C3%BCber.txt"]; + + // As received from Parameterized::params() + yield [['lang' => 'de', 'value' => 'über.txt'], "filename*=UTF-8'de'%C3%BCber.txt"]; } #[Test] @@ -71,10 +94,7 @@ public function headers() { $res= new Response(new TestOutput()); $res->header('Content-Type', 'text/plain'); $res->header('Content-Length', '0'); - Assert::equals( - ['Content-Type' => 'text/plain', 'Content-Length' => '0'], - $res->headers() - ); + Assert::equals(['Content-Type' => 'text/plain', 'Content-Length' => '0'], $res->headers()); } #[Test] @@ -142,7 +162,7 @@ public function cookies() { Assert::equals($cookies, $res->cookies()); } - #[Test, Values([[200, 'OK', 'HTTP/1.1 200 OK'], [404, 'Not Found', 'HTTP/1.1 404 Not Found'], [200, null, 'HTTP/1.1 200 OK'], [404, null, 'HTTP/1.1 404 Not Found'], [200, 'Okay', 'HTTP/1.1 200 Okay'], [404, 'Nope', 'HTTP/1.1 404 Nope']])] + #[Test, Values(from: 'answers')] public function answer($status, $message, $line) { $out= new TestOutput(); @@ -150,7 +170,7 @@ public function answer($status, $message, $line) { $res->answer($status, $message); $res->flush(); - $this->assertResponse($line."\r\n\r\n", $res); + Assert::that($res->output()->bytes())->isEqualTo($line."\r\n\r\n"); } #[Test] @@ -167,7 +187,7 @@ public function hint() { $res->answer(200, 'OK'); $res->flush(); - $this->assertResponse("HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\n\r\n", $res); + Assert::that($res->output()->bytes())->isEqualTo("HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\n\r\n"); } #[Test] @@ -178,12 +198,11 @@ public function hint_with_headers() { 'Connection' => 'Upgrade' ]); - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 101 Switching Protocols\r\n". "Upgrade: websocket\r\n". "Connection: Upgrade\r\n". - "\r\n", - $res + "\r\n" ); } @@ -195,7 +214,7 @@ public function hint_uses_and_retains_previously_set_headers() { $res->answer(200, 'OK'); $res->flush(); - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 103 Early Hints\r\n". "Link: ; rel=preload; as=style\r\n". "Link: ; rel=preload; as=script\r\n". @@ -203,8 +222,7 @@ public function hint_uses_and_retains_previously_set_headers() { "HTTP/1.1 200 OK\r\n". "Link: ; rel=preload; as=style\r\n". "Link: ; rel=preload; as=script\r\n". - "\r\n", - $res + "\r\n" ); } @@ -215,7 +233,12 @@ public function send_headers() { $res->header('Content-Length', '0'); $res->flush(); - $this->assertResponse("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 0\r\n\r\n", $res); + Assert::that($res->output()->bytes())->isEqualTo( + "HTTP/1.1 200 OK\r\n". + "Content-Type: text/plain\r\n". + "Content-Length: 0\r\n". + "\r\n" + ); } #[Test] @@ -223,10 +246,39 @@ public function send_html() { $res= new Response(new TestOutput()); $res->send('