Skip to content

Commit

Permalink
Merge pull request #72 from xp-forge/refactor/async-files
Browse files Browse the repository at this point in the history
Asynchronous file handling
  • Loading branch information
thekid committed Mar 31, 2021
2 parents aede86f + 01d4717 commit 1c63903
Show file tree
Hide file tree
Showing 2 changed files with 78 additions and 107 deletions.
56 changes: 34 additions & 22 deletions src/main/php/web/handler/FilesFrom.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
use web\io\Ranges;

class FilesFrom implements Handler {
const BOUNDARY = '594fa07300f865fe';
const BOUNDARY = '594fa07300f865fe';
const CHUNKSIZE = 8192;

private $path;

Expand Down Expand Up @@ -45,20 +46,25 @@ public function handle($request, $response) {
$file= $target->asFile();
}

$this->serve($request, $response, $file);
return $this->serve($request, $response, $file);
}

/**
* Copies a given amount of bytes from the specified file to the output
*
* @param web.io.Output $output
* @param io.File $file
* @param int $length
* @param web.io.Range $range
* @return iterable
*/
private function copy($output, $file, $length) {
while ($length && $chunk= $file->read(min(8192, $length))) {
private function copy($output, $file, $range) {
$file->seek($range->start());

$length= $range->length();
while ($length && $chunk= $file->read(min(self::CHUNKSIZE, $length))) {
$output->write($chunk);
$length-= strlen($chunk);
yield;
}
}

Expand Down Expand Up @@ -94,7 +100,19 @@ public function serve($request, $response, $target) {
$mimeType= MimeType::getByFileName($file->filename);
if (null === ($ranges= Ranges::in($request->header('Range'), $file->size()))) {
$response->answer(200, 'OK');
$response->transfer($file->in(), $mimeType, $file->size());
$response->header('Content-Type', $mimeType);

$out= $response->stream($file->size());
$file->open(File::READ);
try {
do {
$out->write($file->read(self::CHUNKSIZE));
yield;
} while (!$file->eof());
} finally {
$file->close();
$out->close();
}
return;
}

Expand All @@ -106,47 +124,41 @@ public function serve($request, $response, $target) {
}

$file->open(File::READ);
$output= $response->output();
$response->answer(206, 'Partial Content');

try {
if ($range= $ranges->single()) {
$response->header('Content-Type', $mimeType);
$response->header('Content-Range', $ranges->format($range));
$response->header('Content-Length', $range->length());

$file->seek($range->start());
$response->flush();
$this->copy($output, $file, $range->length());
$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) {
$header= sprintf(
$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)
);
$headers[$i]= $header;
$length+= strlen($header) + $range->length();
}

$response->header('Content-Type', 'multipart/byteranges; boundary='.self::BOUNDARY);
$response->header('Content-Length', $length);
$response->flush();

$out= $response->stream($length);
foreach ($ranges->sets() as $i => $range) {
$output->write($headers[$i]);
$file->seek($range->start());
$this->copy($output, $file, $range->length());
$out->write($headers[$i]);
yield from $this->copy($out, $file, $range);
}
$output->write($trailer);
$out->write($trailer);
}
} finally {
$file->close();
$output->close();
$out->close();
}
}
}
129 changes: 44 additions & 85 deletions src/test/php/web/unittest/handler/FilesFromTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,24 @@ private function assertResponse($expected, $response) {
));
}

/**
* Returns
*
* @param web.handler.FilesFrom $files
* @param web.Request $req
* @return web.Response
*/
private function handle($files, $req) {
$res= new Response(new TestOutput());

try {
foreach ($files->handle($req, $res) ?? [] as $_) { }
return $res;
} finally {
$res->end();
}
}

/** @return void */
public function tearDown() {
foreach ($this->cleanup as $folder) {
Expand All @@ -70,12 +88,7 @@ public function can_create() {

#[Test]
public function existing_file() {
$req= new Request(new TestInput('GET', '/test.html'));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['test.html' => 'Test'])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith(['test.html' => 'Test']));
$this->assertResponse(
"HTTP/1.1 200 OK\r\n".
"Accept-Ranges: bytes\r\n".
Expand All @@ -85,33 +98,25 @@ public function existing_file() {
"Content-Length: 4\r\n".
"\r\n".
"Test",
$res
$this->handle($files, new Request(new TestInput('GET', '/test.html')))
);
}

#[Test]
public function existing_file_unmodified_since() {
$req= new Request(new TestInput('GET', '/test.html', ['If-Modified-Since' => gmdate('D, d M Y H:i:s T', time() + 1)]));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['test.html' => 'Test'])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith(['test.html' => 'Test']));
$this->assertResponse(
"HTTP/1.1 304 Not Modified\r\n".
"\r\n",
$res
$this->handle($files, new Request(new TestInput('GET', '/test.html', [
'If-Modified-Since' => gmdate('D, d M Y H:i:s T', time() + 1)
])))
);
}

#[Test]
public function index_html() {
$req= new Request(new TestInput('GET', '/'));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['index.html' => 'Home'])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith(['index.html' => 'Home']));
$this->assertResponse(
"HTTP/1.1 200 OK\r\n".
"Accept-Ranges: bytes\r\n".
Expand All @@ -121,89 +126,63 @@ public function index_html() {
"Content-Length: 4\r\n".
"\r\n".
"Home",
$res
$this->handle($files, new Request(new TestInput('GET', '/')))
);
}

#[Test]
public function redirect_if_trailing_slash_missing() {
$req= new Request(new TestInput('GET', '/preview'));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['preview' => ['index.html' => 'Home']])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith(['preview' => ['index.html' => 'Home']]));
$this->assertResponse(
"HTTP/1.1 301 Moved Permanently\r\n".
"Location: preview/\r\n".
"\r\n",
$res
$this->handle($files, new Request(new TestInput('GET', '/preview')))
);
}

#[Test]
public function non_existant_file() {
$req= new Request(new TestInput('GET', '/test.html'));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith([])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith([]));
$this->assertResponse(
"HTTP/1.1 404 Not Found\r\n".
"Content-Type: text/plain\r\n".
"Content-Length: 35\r\n".
"\r\n".
"The file '/test.html' was not found",
$res
$this->handle($files, new Request(new TestInput('GET', '/test.html')))
);
}

#[Test]
public function non_existant_index_html() {
$req= new Request(new TestInput('GET', '/'));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith([])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith([]));
$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",
$res
$this->handle($files, new Request(new TestInput('GET', '/')))
);
}

#[Test, Values(['/../credentials', '/static/../../credentials'])]
public function cannot_access_below_path_root($uri) {
$req= new Request(new TestInput('GET', $uri));
$res= new Response(new TestOutput());

$path= $this->pathWith(['credentials' => 'secret']);
$files= new FilesFrom(new Folder($path, 'webroot'));
$files->handle($req, $res);

$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",
$res
$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) {
$req= new Request(new TestInput('GET', '/', ['Range' => 'bytes='.$range]));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['index.html' => 'Homepage'])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith(['index.html' => 'Homepage']));
$this->assertResponse(
"HTTP/1.1 206 Partial Content\r\n".
"Accept-Ranges: bytes\r\n".
Expand All @@ -214,18 +193,13 @@ public function range_with_start_and_end($range, $result) {
"Content-Length: ".strlen($result)."\r\n".
"\r\n".
$result,
$res
$this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes='.$range])))
);
}

#[Test]
public function range_from_offset_until_end() {
$req= new Request(new TestInput('GET', '/', ['Range' => 'bytes=4-']));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['index.html' => 'Homepage'])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith(['index.html' => 'Homepage']));
$this->assertResponse(
"HTTP/1.1 206 Partial Content\r\n".
"Accept-Ranges: bytes\r\n".
Expand All @@ -236,18 +210,13 @@ public function range_from_offset_until_end() {
"Content-Length: 4\r\n".
"\r\n".
"page",
$res
$this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes=4-'])))
);
}

#[Test, Values([0, 8192, 10000])]
public function range_last_four_bytes($offset) {
$req= new Request(new TestInput('GET', '/', ['Range' => 'bytes=-4']));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['index.html' => str_repeat('*', $offset).'Homepage'])));
$files->handle($req, $res);

$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".
Expand All @@ -258,37 +227,27 @@ public function range_last_four_bytes($offset) {
"Content-Length: 4\r\n".
"\r\n".
"page",
$res
$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) {
$req= new Request(new TestInput('GET', '/', ['Range' => $range]));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['index.html' => 'Homepage'])));
$files->handle($req, $res);

$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",
$res
$this->handle($files, new Request(new TestInput('GET', '/', ['Range' => $range])))
);
}

#[Test]
public function multi_range() {
$req= new Request(new TestInput('GET', '/', ['Range' => 'bytes=0-3,4-7']));
$res= new Response(new TestOutput());

$files= (new FilesFrom($this->pathWith(['index.html' => 'Homepage'])));
$files->handle($req, $res);

$files= new FilesFrom($this->pathWith(['index.html' => 'Homepage']));
$this->assertResponse(
"HTTP/1.1 206 Partial Content\r\n".
"Accept-Ranges: bytes\r\n".
Expand All @@ -306,7 +265,7 @@ public function multi_range() {
"Content-Range: bytes 4-7/8\r\n\r\n".
"page".
"\r\n--594fa07300f865fe--\r\n",
$res
$this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes=0-3,4-7'])))
);
}
}

0 comments on commit 1c63903

Please sign in to comment.