diff --git a/.gitignore b/.gitignore index 37ce5fa..126ec8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor *.log *.pem +.phpunit.result.cache diff --git a/src/Orbit/Config.php b/src/Orbit/Config.php index 8f1a4c6..f107a0e 100644 --- a/src/Orbit/Config.php +++ b/src/Orbit/Config.php @@ -53,21 +53,31 @@ class Config * @param string $filename Path to ini file * @return void */ - public function readFromIniFile(string $filename) + public function readFromIniFile(string $filename): void { if (!file_exists($filename) || !is_readable($filename)) { throw new \Exception("Cannot read config file '$filename'"); } $ini = parse_ini_file($filename); + $this->readFromArray($ini); + } + /** + * Read config values from array + * + * @param array $data Array of config values + * @return void + */ + public function readFromArray(array $params): void + { $valid_keys = [ 'host', 'port', 'hostname', 'tls_certfile', 'tls_keyfile', 'keypassphrase', 'log_file', 'log_level', - 'root_dir', 'index_file', 'enable_directory_index' + 'root_dir', 'index_file', 'enable_directory_index', ]; - foreach ($ini as $key => $value) { + foreach ($params as $key => $value) { if (!in_array($key, $valid_keys)) { continue; } diff --git a/src/Orbit/Module.php b/src/Orbit/Module.php index 46e49fb..7897970 100644 --- a/src/Orbit/Module.php +++ b/src/Orbit/Module.php @@ -11,7 +11,7 @@ use Monolog\Logger; * * @package Orbit */ -class Module +class Module implements ModuleInterface { protected $config; protected $logger; @@ -28,4 +28,16 @@ class Module $this->config = $config; $this->logger = $logger; } + + /** + * Handle + * + * Take a request and return a response + * + * This should be implemented in a child class + */ + public function handle(Request $request): array + { + return [true, new Response()]; + } } diff --git a/src/Orbit/Module/Statics.php b/src/Orbit/Module/Statics.php index fbb75fc..a5e8772 100644 --- a/src/Orbit/Module/Statics.php +++ b/src/Orbit/Module/Statics.php @@ -14,15 +14,17 @@ use Orbit\Response; */ class Statics extends Module { + const WORLD_READABLE = 0x0004; + /** * Handle a request and generate a proper response * * @param Request $request The request object - * @param Response $response The already created response object - * @param string $real_root_dir The real path to root on disk */ - public function handleResponse(Request $request, Response $response, $real_root_dir): array + public function handle(Request $request): array { + $response = new Response(); + $real_root_dir = realpath($this->config->root_dir); $resource_path = rtrim($real_root_dir, "/") . $request->path; // Check if within the server root @@ -31,7 +33,7 @@ class Statics extends Module if ($realpath && strpos($realpath, $real_root_dir) !== 0) { $response->setStatus(Response::STATUS_PERMANENT_FAILURE); $response->setMeta("Invalid location"); - return [true, $response]; + return [false, $response]; } if (is_dir($resource_path)) { @@ -39,7 +41,7 @@ class Statics extends Module if ($resource_path[-1] != "/") { $response->setStatus(Response::STATUS_REDIRECT_PERMANENT); $response->setMeta($request->getUrlAppendPath('/')); - return [true, $response]; + return [false, $response]; } // Check if index file exists @@ -47,9 +49,9 @@ class Statics extends Module $resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file; } else { if (!$this->config->enable_directory_index) { - $response->setStatus(Response::STATUS_BAD_REQUEST); + $response->setStatus(Response::STATUS_NOT_FOUND); $response->setMeta('Path not available'); - return [true, $response]; + return [false, $response]; } else { $response->setStatus(Response::STATUS_SUCCESS); $response->setMeta('text/gemini'); @@ -60,22 +62,21 @@ class Statics extends Module } // File exists and is world readable - if (file_exists($resource_path) && self::isWorldReadble($resource_path)) { + if (file_exists($resource_path) && self::isWorldReadable($resource_path)) { $response->setStatus(Response::STATUS_SUCCESS); $pathinfo = pathinfo($resource_path); - // TODO : handle files without extensions - if (!isset($pathinfo['extension'])) { - $response->setStatus(Response::STATUS_TEMPORARY_FAILURE); - $response->setMeta('Error reading resource'); - return [true, $response]; + if (isset($pathinfo['extension'])) { + $meta = $this->getCustomMimeFromFileExtension($pathinfo['extension']); + if (!$meta) { + $meta = mime_content_type($resource_path); + } + } else { + // Use finfo_file to detect type + $meta = finfo_file(finfo_open(FILEINFO_MIME), $resource_path); } - $meta = $this->getCustomMimeFromFileExtension($pathinfo['extension']); - if (!$meta) { - $meta = mime_content_type($resource_path); - } $response->setMeta($meta); $response->setStaticFile($resource_path); } else { @@ -123,8 +124,12 @@ class Statics extends Module { $files = glob($path . "*"); - $body = "# Directory listing " . str_replace($root, '', $path) . "\n\n"; - $body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n"; + $body = "# Directory listing " . str_replace($root, '', $path) . "/\n\n"; + + if ($path != $root . "/") { + // If not already at root, provide option to go up one parent + $body .= "=> " . str_replace($root, '', dirname($path)) . "/ ..\n"; + } foreach ($files as $file) { $relative_path = str_replace($path, '', $file); @@ -149,4 +154,15 @@ class Statics extends Module return $body; } + + /** + * Report whether a given file is world readable or not + * + * @param string $file The file to check + * @return bool + */ + public static function isWorldReadable(string $file): bool + { + return (bool)(fileperms($file) & self::WORLD_READABLE); + } } diff --git a/src/Orbit/ModuleInterface.php b/src/Orbit/ModuleInterface.php new file mode 100644 index 0000000..f8e9f41 --- /dev/null +++ b/src/Orbit/ModuleInterface.php @@ -0,0 +1,26 @@ +response handler can do + * + * @package Orbit + */ +interface ModuleInterface +{ + /** + * Handle + * + * Take a request and generate a response + * + * Must return an array with two values: [bool, Response]; + * The first value (bool) must indicate whether the response should + * continue on or if it should be returned immediately to the client. + */ + public function handle(Request $request): array; +} diff --git a/src/Orbit/Response.php b/src/Orbit/Response.php index 0415f32..f868874 100644 --- a/src/Orbit/Response.php +++ b/src/Orbit/Response.php @@ -60,7 +60,7 @@ class Response } /** - * Send data to client + * Send response body to client * * @param resource $client * @return int|false Number of bytes written @@ -152,11 +152,21 @@ class Response * @param int $status * @return void */ - public function setStatus($status): void + public function setStatus(int $status): void { $this->status = $status; } + /** + * Get status + * + * @return int + */ + public function getStatus(): int + { + return $this->status; + } + /** * Set response meta value * @@ -167,4 +177,14 @@ class Response { $this->meta = $meta; } + + /** + * Get meta + * + * @return string Meta value + */ + public function getMeta(): string + { + return $this->meta; + } } diff --git a/src/Orbit/Server.php b/src/Orbit/Server.php index 869782d..f4ce88c 100644 --- a/src/Orbit/Server.php +++ b/src/Orbit/Server.php @@ -16,7 +16,6 @@ use Orbit\Module\Statics; class Server { const SCHEME = "gemini"; - const WORLD_READABLE = 0x0004; public static $version = "0.4"; @@ -29,7 +28,6 @@ class Server private $connections = []; // Incoming client connections private $peers = []; // Client connections to read from private $metas = []; // Meta data for each client connection - private $real_root_dir; /** * Constructor @@ -84,21 +82,17 @@ class Server /** * Listen and handle incoming requests * - * @param mixed $root_dir The root directory from which to serve files * @return void */ - public function listen($root_dir = null): void + public function listen(): void { - if (null == $root_dir) { - $root_dir = $this->config->root_dir; - } + $root_dir = $this->config->root_dir; if (!is_dir($root_dir)) { throw new \Exception("Error: Root directory '$root_dir' not a directory"); } - $this->real_root_dir = realpath($root_dir); - $this->logger->debug(sprintf("Root directory '%s'", $this->real_root_dir)); + $this->logger->debug(sprintf("Root directory '%s'", realpath($root_dir))); $server = stream_socket_server( $this->getListenAddress(), @@ -201,7 +195,7 @@ class Server $request = new Request($request_buffer); // Respond to client - $response = $this->handleResponse($request); + $response = $this->handle($request); $size = 0; try { @@ -229,7 +223,7 @@ class Server * @param Request $request * @return Response */ - public function handleResponse(Request $request): Response + public function handle(Request $request): Response { [$is_valid, $response] = $this->validateRequest($request); if ($is_valid === false) { @@ -239,22 +233,11 @@ class Server } $statics_module = new Statics($this->config, $this->logger); - [$success, $response] = $statics_module->handleResponse($request, $response, $this->real_root_dir); + [$success, $response] = $statics_module->handle($request); return $response; } - /** - * Report whether a given file is world readable or not - * - * @param string $file The file to check - * @return bool - */ - public static function isWorldReadble(string $file): bool - { - return (bool)(fileperms($file) & self::WORLD_READABLE); - } - /** * Prune any lingering connections * diff --git a/tests/src/Orbit/Module/StaticsTest.php b/tests/src/Orbit/Module/StaticsTest.php new file mode 100644 index 0000000..d431330 --- /dev/null +++ b/tests/src/Orbit/Module/StaticsTest.php @@ -0,0 +1,191 @@ +readFromArray($config_params); + $statics = new Statics($config, new Logger('test-orbit')); + return $statics; + } + + public function testConstruct(): void + { + $statics = new Statics(new Config(), new Logger('test-orbit')); + $this->assertInstanceOf(Statics::class, $statics); + } + + public function testHandle(): void + { + $statics = $this->makeObject(); + $request = new Request('lmnop'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertTrue($success); + } + + public function testHandleFileAttemptAboveRoot(): void + { + $statics = $this->makeObject(); + $request = new Request('/../README.md'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_PERMANENT_FAILURE, $response->getStatus()); + $this->assertFalse($success); + } + + public function testHandleDirectoryWithRedirect(): void + { + @mkdir('dir1'); + + $statics = $this->makeObject(); + $request = new Request('/dir1'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_REDIRECT_PERMANENT, $response->getStatus()); + $this->assertSame('gemini:///dir1/', $response->getMeta()); + $this->assertFalse($success); + + @rmdir('dir1'); + } + + public function testHandleDirectoryWithIndexFile(): void + { + @mkdir('dir1'); + file_put_contents('dir1/index.gmi', '# Sunlit lands'); + + $statics = $this->makeObject(); + $request = new Request('/dir1/'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_SUCCESS, $response->getStatus()); + $this->assertSame('# Sunlit lands', $response->getBody()); + $this->assertTrue($success); + + unlink('dir1/index.gmi'); + @rmdir('dir1'); + } + + public function testHandleNoDirectoryIndex(): void + { + @mkdir('dir1'); + + $statics = $this->makeObject(['enable_directory_index' => false]); + $request = new Request('/dir1/'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertSame('Path not available', $response->getMeta()); + $this->assertFalse($success); + + @rmdir('dir1'); + } + + public function testHandleCustomDirectoryIndex(): void + { + @mkdir('dir1'); + file_put_contents('dir1/INDEX', '# Welcome to index'); + + $statics = $this->makeObject(['index_file' => 'INDEX']); + $request = new Request('/dir1/'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_SUCCESS, $response->getStatus()); + $this->assertStringContainsString('text/plain', $response->getMeta()); + $this->assertStringContainsString('# Welcome to index', $response->getBody()); + $this->assertTrue($success); + + @unlink('dir1/INDEX'); + @rmdir('dir1'); + } + + public function testHandleMakeDirectoryIndex(): void + { + @mkdir('dir1'); + file_put_contents('dir1/abc.txt', 'ABCDEF'); + file_put_contents('dir1/def.txt', 'XFFFF'); + + $statics = $this->makeObject(['enable_directory_index' => true]); + $request = new Request('/dir1/'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_SUCCESS, $response->getStatus()); + $this->assertSame('text/gemini', $response->getMeta()); + $this->assertStringContainsString('abc.txt', $response->getBody()); + $this->assertTrue($success); + + @unlink('dir1/abc.txt'); + @unlink('dir1/def.txt'); + @rmdir('dir1'); + } + + public function testHandleFileNotFound(): void + { + $statics = $this->makeObject(); + $request = new Request('foobar.txt'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertSame('Not found!', $response->getMeta()); + $this->assertTrue($success); + } + + public function testCustomMimeTypes(): void + { + file_put_contents('xyz.gmi', '# Make it great'); + + $statics = $this->makeObject(); + $request = new Request('/xyz.gmi'); + + [$success, $response] = $statics->handle($request); + $this->assertSame(Response::STATUS_SUCCESS, $response->getStatus()); + $this->assertSame('text/gemini', $response->getMeta()); + $this->assertTrue($success); + + unlink('xyz.gmi'); + } + + public function testGetCustomMimeFromFileExtension(): void + { + $statics = $this->makeObject(); + + $this->assertEquals('text/gemini', $statics->getCustomMimeFromFileExtension('gmi')); + $this->assertEquals('text/gemini', $statics->getCustomMimeFromFileExtension('gemini')); + $this->assertEquals('text/gemini', $statics->getCustomMimeFromFileExtension('md')); + $this->assertEquals('text/gemini', $statics->getCustomMimeFromFileExtension('markdown')); + $this->assertEquals('text/x-ansi', $statics->getCustomMimeFromFileExtension('ans')); + $this->assertEquals('text/x-ansi', $statics->getCustomMimeFromFileExtension('ansi')); + $this->assertEquals('', $statics->getCustomMimeFromFileExtension('hoo-haw')); + } + + public function testMakeDirectoryIndexWithSubdirs(): void + { + mkdir('dir1'); + mkdir('dir1/sub1'); + file_put_contents('dir1/foo.txt', 'foo1'); + file_put_contents('dir1/sub1/bar.txt', 'bar1'); + + $statics = $this->makeObject(); + $results = $statics->makeDirectoryIndex('', realpath('./')); + $this->assertStringContainsString('# Directory listing', $results); + $this->assertStringContainsString('=> / ..', $results); + $this->assertStringContainsString('=> dir1/ dir1/', $results); + + unlink('dir1/foo.txt'); + unlink('dir1/sub1/bar.txt'); + rmdir('dir1/sub1'); + rmdir('dir1'); + } +} diff --git a/tests/src/Orbit/ModuleTest.php b/tests/src/Orbit/ModuleTest.php new file mode 100644 index 0000000..d43038b --- /dev/null +++ b/tests/src/Orbit/ModuleTest.php @@ -0,0 +1,29 @@ +assertInstanceOf(Module::class, $module); + } + + public function testHandle(): void + { + $module = new Module(new Config(), new Logger('foobar')); + [$success, $response] = $module->handle(new Request('foobar')); + + $this->assertTrue($success); + $this->assertInstanceOf(Response::class, $response); + } +}