diff --git a/src/main/php/web/handler/FilesFrom.class.php b/src/main/php/web/handler/FilesFrom.class.php index 0d52e75f..1e04483a 100755 --- a/src/main/php/web/handler/FilesFrom.class.php +++ b/src/main/php/web/handler/FilesFrom.class.php @@ -1,20 +1,16 @@ <?php namespace web\handler; use io\{File, Path}; -use util\MimeType; -use web\io\Ranges; -use web\{Handler, Headers}; +use web\Handler; +use web\io\StaticContent; class FilesFrom implements Handler { - const BOUNDARY = '594fa07300f865fe'; - const CHUNKSIZE = 8192; - - private $path; - private $headers= []; + private $path, $content; /** @param io.Path|io.Folder|string $path */ public function __construct($path) { $this->path= $path instanceof Path ? $path : new Path($path); + $this->content= new StaticContent(); } /** @return io.Path */ @@ -27,7 +23,7 @@ public function path() { return $this->path; } * @return self */ public function with($headers) { - $this->headers= $headers; + $this->content->with($headers); return $this; } @@ -61,31 +57,13 @@ public function handle($request, $response) { $file= $target->asFile(); } - return $this->serve($request, $response, $file); - } - - /** - * Copies a given amount of bytes from the specified file to the output - * - * @param web.io.Output $out - * @param io.File $file - * @param web.io.Range $range - * @return iterable - */ - private function copy($out, $file, $range) { - $file->seek($range->start()); - - $length= $range->length(); - while ($length && $chunk= $file->read(min(self::CHUNKSIZE, $length))) { - yield 'write' => null; - $out->write($chunk); - $length-= strlen($chunk); - } + return $this->content->serve($request, $response, $file); } /** - * Serves a single file + * Serves a single file. * + * @deprecated Use `web.io.StaticContent` directly! * @param web.Request $request * @param web.Response $response * @param ?io.File|io.Path|string $target @@ -93,96 +71,6 @@ private function copy($out, $file, $range) { * @return iterable */ public function serve($request, $response, $target, $mimeType= null) { - if (null === $target || ($file= $target instanceof File ? $target : new File($target)) && !$file->exists()) { - $response->answer(404, 'Not Found'); - $response->send('The file \''.$request->uri()->path().'\' was not found', 'text/plain'); - return; - } - - $lastModified= $file->lastModified(); - if ($conditional= $request->header('If-Modified-Since')) { - if ($lastModified <= strtotime($conditional)) { - $response->answer(304, 'Not Modified'); - $response->flush(); - return; - } - } - - $mimeType ?? $mimeType= MimeType::getByFileName($file->filename); - $response->header('Accept-Ranges', 'bytes'); - $response->header('Last-Modified', Headers::date($lastModified)); - $response->header('X-Content-Type-Options', 'nosniff'); - $headers= is_callable($this->headers) ? ($this->headers)($request->uri(), $target, $mimeType) : $this->headers; - foreach ($headers as $name => $value) { - $response->header($name, $value); - } - - if (null === ($ranges= Ranges::in($request->header('Range'), $file->size()))) { - $response->answer(200, 'OK'); - $response->header('Content-Type', $mimeType); - - if ('HEAD' === $request->method()) { - $response->header('Content-Length', $file->size()); - $response->flush(); - } else { - $out= $response->stream($file->size()); - $file->open(File::READ); - try { - do { - yield 'write' => null; - $out->write($file->read(self::CHUNKSIZE)); - } while (!$file->eof()); - } finally { - $file->close(); - $out->close(); - } - } - return; - } - - if (!$ranges->satisfiable() || 'bytes' !== $ranges->unit()) { - $response->answer(416, 'Range Not Satisfiable'); - $response->header('Content-Range', 'bytes */'.$ranges->complete()); - $response->flush(); - return; - } - - $file->open(File::READ); - $response->answer(206, 'Partial Content'); - - try { - if ($range= $ranges->single()) { - $response->header('Content-Type', $mimeType); - $response->header('Content-Range', $ranges->format($range)); - - $out= $response->stream($range->length()); - yield from $this->copy($out, $file, $range); - } else { - $headers= []; - $trailer= "\r\n--".self::BOUNDARY."--\r\n"; - $length= strlen($trailer); - - foreach ($ranges->sets() as $i => $range) { - $headers[$i]= $header= sprintf( - "\r\n--%s\r\nContent-Type: %s\r\nContent-Range: %s\r\n\r\n", - self::BOUNDARY, - $mimeType, - $ranges->format($range) - ); - $length+= strlen($header) + $range->length(); - } - $response->header('Content-Type', 'multipart/byteranges; boundary='.self::BOUNDARY); - - $out= $response->stream($length); - foreach ($ranges->sets() as $i => $range) { - $out->write($headers[$i]); - yield from $this->copy($out, $file, $range); - } - $out->write($trailer); - } - } finally { - $file->close(); - $out->close(); - } + return $this->content->serve($request, $response, $target, $mimeType); } } \ No newline at end of file diff --git a/src/main/php/web/io/StaticContent.class.php b/src/main/php/web/io/StaticContent.class.php new file mode 100755 index 00000000..f5aa6d0f --- /dev/null +++ b/src/main/php/web/io/StaticContent.class.php @@ -0,0 +1,160 @@ +<?php namespace web\io; + +use io\File; +use util\MimeType; +use web\Headers; + +/** + * Serves static content by streaming given files. Handles HEAD, + * conditional and byte range requests. Suppresses content type + * detection by adding `X-Content-Type-Options: nosniff`. + * + * ```php + * $content= (new StaticContent())->with(['Cache-Control' => '...']); + * return $content->serve($req, $res, new File('...'), 'image/gif'); + * ``` + * + * @test web.unittest.io.StaticContentTest + * @see web.handler.FilesFrom + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + */ +class StaticContent { + const BOUNDARY = '594fa07300f865fe'; + const CHUNKSIZE = 8192; + + private $headers= []; + + /** + * Adds headers to successful responses, either from an array or a function. + * + * @param [:string]|function(util.URI, io.File, string): iterable $headers + * @return self + */ + public function with($headers) { + $this->headers= $headers; + return $this; + } + + /** + * Copies a given amount of bytes from the specified file to the output + * + * @param web.io.Output $out + * @param io.File $file + * @param web.io.Range $range + * @return iterable + */ + private function copy($out, $file, $range) { + $file->seek($range->start()); + + $length= $range->length(); + while ($length && $chunk= $file->read(min(self::CHUNKSIZE, $length))) { + yield 'write' => $out; + $out->write($chunk); + $length-= strlen($chunk); + } + } + + /** + * Serves a single file. If no mime type is given, it is detected from + * the given file's extension. + * + * @param web.Request $request + * @param web.Response $response + * @param ?io.File|io.Path|string $target + * @param ?string $mimeType + * @return iterable + */ + public function serve($request, $response, $target, $mimeType= null) { + if (null === $target || ($file= $target instanceof File ? $target : new File($target)) && !$file->exists()) { + $response->answer(404, 'Not Found'); + $response->send('The file \''.$request->uri()->path().'\' was not found', 'text/plain'); + return; + } + + $lastModified= $file->lastModified(); + if ($conditional= $request->header('If-Modified-Since')) { + if ($lastModified <= strtotime($conditional)) { + $response->answer(304, 'Not Modified'); + $response->flush(); + return; + } + } + + $mimeType ?? $mimeType= MimeType::getByFileName($file->filename); + $response->header('Accept-Ranges', 'bytes'); + $response->header('Last-Modified', Headers::date($lastModified)); + $response->header('X-Content-Type-Options', 'nosniff'); + $headers= is_callable($this->headers) ? ($this->headers)($request->uri(), $target, $mimeType) : $this->headers; + foreach ($headers as $name => $value) { + $response->header($name, $value); + } + + if (null === ($ranges= Ranges::in($request->header('Range'), $file->size()))) { + $response->answer(200, 'OK'); + $response->header('Content-Type', $mimeType); + + if ('HEAD' === $request->method()) { + $response->header('Content-Length', $file->size()); + $response->flush(); + } else { + $out= $response->stream($file->size()); + $file->open(File::READ); + try { + do { + yield 'write' => $out; + $out->write($file->read(self::CHUNKSIZE)); + } while (!$file->eof()); + } finally { + $file->close(); + $out->close(); + } + } + return; + } + + if (!$ranges->satisfiable() || 'bytes' !== $ranges->unit()) { + $response->answer(416, 'Range Not Satisfiable'); + $response->header('Content-Range', 'bytes */'.$ranges->complete()); + $response->flush(); + return; + } + + $file->open(File::READ); + $response->answer(206, 'Partial Content'); + + try { + if ($range= $ranges->single()) { + $response->header('Content-Type', $mimeType); + $response->header('Content-Range', $ranges->format($range)); + + $out= $response->stream($range->length()); + yield from $this->copy($out, $file, $range); + } else { + $headers= []; + $trailer= "\r\n--".self::BOUNDARY."--\r\n"; + $length= strlen($trailer); + + foreach ($ranges->sets() as $i => $range) { + $headers[$i]= $header= sprintf( + "\r\n--%s\r\nContent-Type: %s\r\nContent-Range: %s\r\n\r\n", + self::BOUNDARY, + $mimeType, + $ranges->format($range) + ); + $length+= strlen($header) + $range->length(); + } + $response->header('Content-Type', 'multipart/byteranges; boundary='.self::BOUNDARY); + + $out= $response->stream($length); + foreach ($ranges->sets() as $i => $range) { + $out->write($headers[$i]); + yield from $this->copy($out, $file, $range); + } + $out->write($trailer); + } + } finally { + $file->close(); + $out->close(); + } + } +} \ No newline at end of file diff --git a/src/test/php/web/unittest/handler/FilesFromTest.class.php b/src/test/php/web/unittest/handler/FilesFromTest.class.php index dfb5380b..80f7061c 100755 --- a/src/test/php/web/unittest/handler/FilesFromTest.class.php +++ b/src/test/php/web/unittest/handler/FilesFromTest.class.php @@ -44,59 +44,27 @@ private function pathWith($files) { } /** - * Assertion helper - * - * @param string $expected - * @param web.Response $response - * @throws unittest.AssertionFailedError - */ - private function assertResponse($expected, $response) { - Assert::equals($expected, preg_replace( - '/[a-z]{3}, [0-9]{2} [a-z]{3} [0-9]{4} [0-9:]{8} GMT/i', - '<Date>', - $response->output()->bytes() - )); - } - - /** - * Invokes handle() + * Invokes `handle()` * * @param web.handler.FilesFrom $files * @param web.Request $req - * @return web.Response + * @return string */ private function handle($files, $req) { $res= new Response(new TestOutput()); - try { foreach ($files->handle($req, $res) ?? [] as $_) { } - return $res; } finally { $res->end(); } - } - /** - * Invokes serve() - * - * @param web.handler.FilesFrom $files - * @param io.File $file - * @param ?string $mime - * @return web.Response - */ - private function serve($files, $file, $mime= null) { - $res= new Response(new TestOutput()); - $req= new Request(new TestInput('GET', '/')); - - try { - foreach ($files->serve($req, $res, $file, $mime) ?? [] as $_) { } - return $res; - } finally { - $res->end(); - } + return preg_replace( + '/[a-z]{3}, [0-9]{2} [a-z]{3} [0-9]{4} [0-9:]{8} GMT/i', + '<Date>', + $res->output()->bytes() + ); } - /** @return void */ #[After] public function tearDown() { foreach ($this->cleanup as $folder) { @@ -117,7 +85,7 @@ public function path($arg) { #[Test] public function existing_file() { $files= new FilesFrom($this->pathWith(['test.html' => 'Test'])); - $this->assertResponse( + Assert::equals( "HTTP/1.1 200 OK\r\n". "Accept-Ranges: bytes\r\n". "Last-Modified: <Date>\r\n". @@ -133,7 +101,7 @@ public function existing_file() { #[Test] public function existing_file_with_headers() { $files= (new FilesFrom($this->pathWith(['test.html' => 'Test'])))->with(['Cache-Control' => 'no-cache']); - $this->assertResponse( + Assert::equals( "HTTP/1.1 200 OK\r\n". "Accept-Ranges: bytes\r\n". "Last-Modified: <Date>\r\n". @@ -154,7 +122,7 @@ public function existing_file_with_headers_function() { yield 'Cache-Control' => 'no-cache'; } }); - $this->assertResponse( + Assert::equals( "HTTP/1.1 200 OK\r\n". "Accept-Ranges: bytes\r\n". "Last-Modified: <Date>\r\n". @@ -168,22 +136,10 @@ public function existing_file_with_headers_function() { ); } - #[Test] - public function existing_file_unmodified_since() { - $files= new FilesFrom($this->pathWith(['test.html' => 'Test'])); - $this->assertResponse( - "HTTP/1.1 304 Not Modified\r\n". - "\r\n", - $this->handle($files, new Request(new TestInput('GET', '/test.html', [ - 'If-Modified-Since' => Headers::date(time() + 1) - ]))) - ); - } - #[Test] public function index_html() { $files= new FilesFrom($this->pathWith(['index.html' => 'Home'])); - $this->assertResponse( + Assert::equals( "HTTP/1.1 200 OK\r\n". "Accept-Ranges: bytes\r\n". "Last-Modified: <Date>\r\n". @@ -199,7 +155,7 @@ public function index_html() { #[Test] public function redirect_if_trailing_slash_missing() { $files= new FilesFrom($this->pathWith(['preview' => ['index.html' => 'Home']])); - $this->assertResponse( + Assert::equals( "HTTP/1.1 301 Moved Permanently\r\n". "Location: preview/\r\n". "\r\n", @@ -210,7 +166,7 @@ public function redirect_if_trailing_slash_missing() { #[Test] public function non_existant_file() { $files= new FilesFrom($this->pathWith([])); - $this->assertResponse( + Assert::equals( "HTTP/1.1 404 Not Found\r\n". "Content-Type: text/plain\r\n". "Content-Length: 35\r\n". @@ -223,7 +179,7 @@ public function non_existant_file() { #[Test] public function non_existant_index_html() { $files= new FilesFrom($this->pathWith([])); - $this->assertResponse( + Assert::equals( "HTTP/1.1 404 Not Found\r\n". "Content-Type: text/plain\r\n". "Content-Length: 26\r\n". @@ -233,113 +189,10 @@ public function non_existant_index_html() { ); } - #[Test, Values(['/../credentials', '/static/../../credentials'])] - public function cannot_access_below_path_root($uri) { - $files= new FilesFrom(new Folder($this->pathWith(['credentials' => 'secret']), 'webroot')); - $this->assertResponse( - "HTTP/1.1 404 Not Found\r\n". - "Content-Type: text/plain\r\n". - "Content-Length: 37\r\n". - "\r\n". - "The file '/credentials' was not found", - $this->handle($files, new Request(new TestInput('GET', $uri))) - ); - } - - #[Test, Values([['0-3', 'Home'], ['4-7', 'page'], ['0-0', 'H'], ['4-4', 'p'], ['7-7', 'e']])] - public function range_with_start_and_end($range, $result) { - $files= new FilesFrom($this->pathWith(['index.html' => 'Homepage'])); - $this->assertResponse( - "HTTP/1.1 206 Partial Content\r\n". - "Accept-Ranges: bytes\r\n". - "Last-Modified: <Date>\r\n". - "X-Content-Type-Options: nosniff\r\n". - "Content-Type: text/html\r\n". - "Content-Range: bytes ".$range."/8\r\n". - "Content-Length: ".strlen($result)."\r\n". - "\r\n". - $result, - $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes='.$range]))) - ); - } - - #[Test] - public function range_from_offset_until_end() { - $files= new FilesFrom($this->pathWith(['index.html' => 'Homepage'])); - $this->assertResponse( - "HTTP/1.1 206 Partial Content\r\n". - "Accept-Ranges: bytes\r\n". - "Last-Modified: <Date>\r\n". - "X-Content-Type-Options: nosniff\r\n". - "Content-Type: text/html\r\n". - "Content-Range: bytes 4-7/8\r\n". - "Content-Length: 4\r\n". - "\r\n". - "page", - $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes=4-']))) - ); - } - - #[Test, Values([0, 8192, 10000])] - public function range_last_four_bytes($offset) { - $files= new FilesFrom($this->pathWith(['index.html' => str_repeat('*', $offset).'Homepage'])); - $this->assertResponse( - "HTTP/1.1 206 Partial Content\r\n". - "Accept-Ranges: bytes\r\n". - "Last-Modified: <Date>\r\n". - "X-Content-Type-Options: nosniff\r\n". - "Content-Type: text/html\r\n". - "Content-Range: bytes ".($offset + 4)."-".($offset + 7)."/".($offset + 8)."\r\n". - "Content-Length: 4\r\n". - "\r\n". - "page", - $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes=-4']))) - ); - } - - #[Test, Values(['bytes=0-2000', 'bytes=4-2000', 'bytes=2000-', 'bytes=2000-2001', 'bytes=2000-0', 'bytes=4-0', 'characters=0-'])] - public function range_unsatisfiable($range) { - $files= new FilesFrom($this->pathWith(['index.html' => 'Homepage'])); - $this->assertResponse( - "HTTP/1.1 416 Range Not Satisfiable\r\n". - "Accept-Ranges: bytes\r\n". - "Last-Modified: <Date>\r\n". - "X-Content-Type-Options: nosniff\r\n". - "Content-Range: bytes */8\r\n". - "\r\n", - $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => $range]))) - ); - } - - #[Test] - public function multi_range() { - $files= new FilesFrom($this->pathWith(['index.html' => 'Homepage'])); - $this->assertResponse( - "HTTP/1.1 206 Partial Content\r\n". - "Accept-Ranges: bytes\r\n". - "Last-Modified: <Date>\r\n". - "X-Content-Type-Options: nosniff\r\n". - "Content-Type: multipart/byteranges; boundary=594fa07300f865fe\r\n". - "Content-Length: 186\r\n". - "\r\n". - "\r\n--594fa07300f865fe\r\n". - "Content-Type: text/html\r\n". - "Content-Range: bytes 0-3/8\r\n\r\n". - "Home". - "\r\n--594fa07300f865fe\r\n". - "Content-Type: text/html\r\n". - "Content-Range: bytes 4-7/8\r\n\r\n". - "page". - "\r\n--594fa07300f865fe--\r\n", - $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes=0-3,4-7']))) - ); - } - - #[Test] - public function call_serve_directly() { - $files= new FilesFrom('.'); - $file= new File($this->pathWith(['test.html' => 'Test']), 'test.html'); - $this->assertResponse( + #[Test, Values(['/./test.html', '/static/../test.html'])] + public function resolves_paths($uri) { + $files= new FilesFrom($this->pathWith(['test.html' => 'Test'])); + Assert::equals( "HTTP/1.1 200 OK\r\n". "Accept-Ranges: bytes\r\n". "Last-Modified: <Date>\r\n". @@ -348,66 +201,51 @@ public function call_serve_directly() { "Content-Length: 4\r\n". "\r\n". "Test", - $this->serve($files, $file) - ); - } - - #[Test] - public function call_serve_with_non_existant_file() { - $files= new FilesFrom('.'); - $file= new File($this->pathWith([]), 'test.html'); - $this->assertResponse( - "HTTP/1.1 404 Not Found\r\n". - "Content-Type: text/plain\r\n". - "Content-Length: 26\r\n". - "\r\n". - "The file '/' was not found", - $this->serve($files, $file) + $this->handle($files, new Request(new TestInput('GET', $uri))) ); } - #[Test] - public function call_serve_without_file() { - $files= new FilesFrom('.'); - $this->assertResponse( + #[Test, Values(['/../credentials', '/static/../../credentials'])] + public function cannot_access_below_path_root($uri) { + $files= new FilesFrom(new Folder($this->pathWith(['credentials' => 'secret']), 'webroot')); + Assert::equals( "HTTP/1.1 404 Not Found\r\n". "Content-Type: text/plain\r\n". - "Content-Length: 26\r\n". + "Content-Length: 37\r\n". "\r\n". - "The file '/' was not found", - $this->serve($files, null) + "The file '/credentials' was not found", + $this->handle($files, new Request(new TestInput('GET', $uri))) ); } + /** @deprecated */ #[Test] - public function overrride_mime_type_when_invoking_serve() { + public function call_serve_directly() { $files= new FilesFrom('.'); $file= new File($this->pathWith(['test.html' => 'Test']), 'test.html'); - $this->assertResponse( - "HTTP/1.1 200 OK\r\n". - "Accept-Ranges: bytes\r\n". - "Last-Modified: <Date>\r\n". - "X-Content-Type-Options: nosniff\r\n". - "Content-Type: text/html; charset=utf-8\r\n". - "Content-Length: 4\r\n". - "\r\n". - "Test", - $this->serve($files, $file, 'text/html; charset=utf-8') - ); - } - #[Test] - public function head_method_on_existing_file() { - $files= new FilesFrom($this->pathWith(['test.html' => 'Test'])); - $this->assertResponse( + $res= new Response(new TestOutput()); + $req= new Request(new TestInput('GET', '/test.html')); + try { + foreach ($files->serve($req, $res, $file) ?? [] as $_) { } + } finally { + $res->end(); + } + + Assert::equals( "HTTP/1.1 200 OK\r\n". "Accept-Ranges: bytes\r\n". "Last-Modified: <Date>\r\n". "X-Content-Type-Options: nosniff\r\n". "Content-Type: text/html\r\n". "Content-Length: 4\r\n". - "\r\n", - $this->handle($files, new Request(new TestInput('HEAD', '/test.html'))) + "\r\n". + "Test", + preg_replace( + '/[a-z]{3}, [0-9]{2} [a-z]{3} [0-9]{4} [0-9:]{8} GMT/i', + '<Date>', + $res->output()->bytes() + ) ); } } \ No newline at end of file diff --git a/src/test/php/web/unittest/io/StaticContentTest.class.php b/src/test/php/web/unittest/io/StaticContentTest.class.php new file mode 100755 index 00000000..89430759 --- /dev/null +++ b/src/test/php/web/unittest/io/StaticContentTest.class.php @@ -0,0 +1,277 @@ +<?php namespace web\unittest\io; + +use io\{File, TempFile}; +use test\{Assert, Before, Test, Values}; +use web\io\{StaticContent, TestInput, TestOutput}; +use web\{Request, Response, Headers}; + +class StaticContentTest { + private $file; + + /** + * Invokes `serve()` and returns the HTTP response as a string. + * + * @param web.io.StaticContent $content + * @param ?io.File $file + * @param ?string $mimeType + * @param ?web.io.TestInput $input + * @return string + */ + private function serve($content, $file, $mimeType= null, $input= null) { + $res= new Response(new TestOutput()); + $req= new Request($input ?? new TestInput('GET', '/')); + + try { + foreach ($content->serve($req, $res, $file, $mimeType) ?? [] as $_) { } + } finally { + $res->end(); + } + + return preg_replace( + '/[a-z]{3}, [0-9]{2} [a-z]{3} [0-9]{4} [0-9:]{8} GMT/i', + '<Date>', + $res->output()->bytes() + ); + } + + #[Before] + public function file() { + $this->file= (new TempFile(self::class))->containing('Homepage'); + $this->file->move($this->file->getURI().'.html'); + } + + #[Test] + public function can_create() { + new StaticContent(); + } + + #[Test] + public function serve_file() { + Assert::equals( + "HTTP/1.1 200 OK\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Type: text/html\r\n". + "Content-Length: 8\r\n". + "\r\n". + "Homepage", + $this->serve(new StaticContent(), $this->file, 'text/html') + ); + } + + #[Test] + public function mime_type_inferred_from_file_extension() { + Assert::equals( + "HTTP/1.1 200 OK\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Type: text/html\r\n". + "Content-Length: 8\r\n". + "\r\n". + "Homepage", + $this->serve(new StaticContent(), $this->file) + ); + } + + #[Test] + public function head_requests_do_not_include_body() { + Assert::equals( + "HTTP/1.1 200 OK\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Type: text/html\r\n". + "Content-Length: 8\r\n". + "\r\n", + $this->serve(new StaticContent(), $this->file, null, new TestInput('HEAD', '/')) + ); + } + + #[Test] + public function conditional_request() { + Assert::equals( + "HTTP/1.1 200 OK\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Type: text/html\r\n". + "Content-Length: 8\r\n". + "\r\n". + "Homepage", + $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [ + 'If-Modified-Since' => Headers::date(time() - 86400) + ])) + ); + } + + #[Test] + public function unmodified_since_date_in_conditional_request() { + Assert::equals( + "HTTP/1.1 304 Not Modified\r\n". + "\r\n", + $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [ + 'If-Modified-Since' => Headers::date(time() + 86400) + ])) + ); + } + + #[Test] + public function with_headers() { + $headers= ['Cache-Control' => 'no-cache']; + + Assert::equals( + "HTTP/1.1 200 OK\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Cache-Control: no-cache\r\n". + "Content-Type: text/html\r\n". + "Content-Length: 8\r\n". + "\r\n". + "Homepage", + $this->serve((new StaticContent())->with($headers), $this->file) + ); + } + + #[Test] + public function with_header_function() { + $headers= function($uri, $file, $mime) { + yield 'X-Access-Time' => Headers::date($file->lastAccessed()); + }; + + Assert::equals( + "HTTP/1.1 200 OK\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "X-Access-Time: <Date>\r\n". + "Content-Type: text/html\r\n". + "Content-Length: 8\r\n". + "\r\n". + "Homepage", + $this->serve((new StaticContent())->with($headers), $this->file) + ); + } + + #[Test] + public function serve_non_existant_yields_404() { + Assert::equals( + "HTTP/1.1 404 Not Found\r\n". + "Content-Type: text/plain\r\n". + "Content-Length: 26\r\n". + "\r\n". + "The file '/' was not found", + $this->serve(new StaticContent(), new File('does.not.exist')) + ); + } + + #[Test] + public function serve_null_yields_404() { + Assert::equals( + "HTTP/1.1 404 Not Found\r\n". + "Content-Type: text/plain\r\n". + "Content-Length: 35\r\n". + "\r\n". + "The file '/not-found' was not found", + $this->serve(new StaticContent(), null, null, new TestInput('GET', '/not-found')) + ); + } + + #[Test, Values([['0-3', 'Home'], ['4-7', 'page'], ['0-0', 'H'], ['4-4', 'p'], ['7-7', 'e']])] + public function range_with_start_and_end($range, $result) { + Assert::equals( + "HTTP/1.1 206 Partial Content\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Type: text/html\r\n". + "Content-Range: bytes {$range}/8\r\n". + "Content-Length: ".strlen($result)."\r\n". + "\r\n". + $result, + $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [ + 'Range' => "bytes={$range}" + ])) + ); + } + + #[Test] + public function range_from_offset_until_end() { + Assert::equals( + "HTTP/1.1 206 Partial Content\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Type: text/html\r\n". + "Content-Range: bytes 4-7/8\r\n". + "Content-Length: 4\r\n". + "\r\n". + "page", + $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [ + 'Range' => 'bytes=4-' + ])) + ); + } + + #[Test, Values([0, 8192, 10000])] + public function range_last_four_bytes($offset) { + $padded= (new TempFile(self::class))->containing(str_repeat('*', $offset).'Homepage'); + + Assert::equals( + "HTTP/1.1 206 Partial Content\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Type: text/html\r\n". + "Content-Range: bytes ".($offset + 4)."-".($offset + 7)."/".($offset + 8)."\r\n". + "Content-Length: 4\r\n". + "\r\n". + "page", + $this->serve(new StaticContent(), $padded, 'text/html', new TestInput('GET', '/', [ + 'Range' => 'bytes=-4' + ])) + ); + } + + #[Test] + public function multiple_ranges() { + Assert::equals( + "HTTP/1.1 206 Partial Content\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Type: multipart/byteranges; boundary=594fa07300f865fe\r\n". + "Content-Length: 186\r\n". + "\r\n". + "\r\n--594fa07300f865fe\r\n". + "Content-Type: text/html\r\n". + "Content-Range: bytes 0-3/8\r\n\r\n". + "Home". + "\r\n--594fa07300f865fe\r\n". + "Content-Type: text/html\r\n". + "Content-Range: bytes 4-7/8\r\n\r\n". + "page". + "\r\n--594fa07300f865fe--\r\n", + $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [ + 'Range' => 'bytes=0-3,4-7' + ])) + ); + } + + #[Test, Values(['bytes=0-2000', 'bytes=4-2000', 'bytes=2000-', 'bytes=2000-2001', 'bytes=2000-0', 'bytes=4-0', 'characters=0-'])] + public function range_unsatisfiable($range) { + Assert::equals( + "HTTP/1.1 416 Range Not Satisfiable\r\n". + "Accept-Ranges: bytes\r\n". + "Last-Modified: <Date>\r\n". + "X-Content-Type-Options: nosniff\r\n". + "Content-Range: bytes */8\r\n". + "\r\n", + $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [ + 'Range' => $range + ])) + ); + } +} \ No newline at end of file