Clean up during testing, add module interface

This commit is contained in:
Jansen Price 2020-09-07 17:05:38 -05:00
parent dadd3b4663
commit a1c3d730ca
9 changed files with 336 additions and 48 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
vendor vendor
*.log *.log
*.pem *.pem
.phpunit.result.cache

View file

@ -53,21 +53,31 @@ class Config
* @param string $filename Path to ini file * @param string $filename Path to ini file
* @return void * @return void
*/ */
public function readFromIniFile(string $filename) public function readFromIniFile(string $filename): void
{ {
if (!file_exists($filename) || !is_readable($filename)) { if (!file_exists($filename) || !is_readable($filename)) {
throw new \Exception("Cannot read config file '$filename'"); throw new \Exception("Cannot read config file '$filename'");
} }
$ini = parse_ini_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 = [ $valid_keys = [
'host', 'port', 'hostname', 'tls_certfile', 'host', 'port', 'hostname', 'tls_certfile',
'tls_keyfile', 'keypassphrase', 'log_file', 'log_level', '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)) { if (!in_array($key, $valid_keys)) {
continue; continue;
} }

View file

@ -11,7 +11,7 @@ use Monolog\Logger;
* *
* @package Orbit * @package Orbit
*/ */
class Module class Module implements ModuleInterface
{ {
protected $config; protected $config;
protected $logger; protected $logger;
@ -28,4 +28,16 @@ class Module
$this->config = $config; $this->config = $config;
$this->logger = $logger; $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()];
}
} }

View file

@ -14,15 +14,17 @@ use Orbit\Response;
*/ */
class Statics extends Module class Statics extends Module
{ {
const WORLD_READABLE = 0x0004;
/** /**
* Handle a request and generate a proper response * Handle a request and generate a proper response
* *
* @param Request $request The request object * @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; $resource_path = rtrim($real_root_dir, "/") . $request->path;
// Check if within the server root // Check if within the server root
@ -31,7 +33,7 @@ class Statics extends Module
if ($realpath && strpos($realpath, $real_root_dir) !== 0) { if ($realpath && strpos($realpath, $real_root_dir) !== 0) {
$response->setStatus(Response::STATUS_PERMANENT_FAILURE); $response->setStatus(Response::STATUS_PERMANENT_FAILURE);
$response->setMeta("Invalid location"); $response->setMeta("Invalid location");
return [true, $response]; return [false, $response];
} }
if (is_dir($resource_path)) { if (is_dir($resource_path)) {
@ -39,7 +41,7 @@ class Statics extends Module
if ($resource_path[-1] != "/") { if ($resource_path[-1] != "/") {
$response->setStatus(Response::STATUS_REDIRECT_PERMANENT); $response->setStatus(Response::STATUS_REDIRECT_PERMANENT);
$response->setMeta($request->getUrlAppendPath('/')); $response->setMeta($request->getUrlAppendPath('/'));
return [true, $response]; return [false, $response];
} }
// Check if index file exists // Check if index file exists
@ -47,9 +49,9 @@ class Statics extends Module
$resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file; $resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file;
} else { } else {
if (!$this->config->enable_directory_index) { if (!$this->config->enable_directory_index) {
$response->setStatus(Response::STATUS_BAD_REQUEST); $response->setStatus(Response::STATUS_NOT_FOUND);
$response->setMeta('Path not available'); $response->setMeta('Path not available');
return [true, $response]; return [false, $response];
} else { } else {
$response->setStatus(Response::STATUS_SUCCESS); $response->setStatus(Response::STATUS_SUCCESS);
$response->setMeta('text/gemini'); $response->setMeta('text/gemini');
@ -60,22 +62,21 @@ class Statics extends Module
} }
// File exists and is world readable // 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); $response->setStatus(Response::STATUS_SUCCESS);
$pathinfo = pathinfo($resource_path); $pathinfo = pathinfo($resource_path);
// TODO : handle files without extensions if (isset($pathinfo['extension'])) {
if (!isset($pathinfo['extension'])) { $meta = $this->getCustomMimeFromFileExtension($pathinfo['extension']);
$response->setStatus(Response::STATUS_TEMPORARY_FAILURE); if (!$meta) {
$response->setMeta('Error reading resource'); $meta = mime_content_type($resource_path);
return [true, $response]; }
} 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->setMeta($meta);
$response->setStaticFile($resource_path); $response->setStaticFile($resource_path);
} else { } else {
@ -123,8 +124,12 @@ class Statics extends Module
{ {
$files = glob($path . "*"); $files = glob($path . "*");
$body = "# Directory listing " . str_replace($root, '', $path) . "\n\n"; $body = "# Directory listing " . str_replace($root, '', $path) . "/\n\n";
$body .= "=> " . str_replace($root, '', dirname($path)) . " ..\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) { foreach ($files as $file) {
$relative_path = str_replace($path, '', $file); $relative_path = str_replace($path, '', $file);
@ -149,4 +154,15 @@ class Statics extends Module
return $body; 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);
}
} }

View file

@ -0,0 +1,26 @@
<?php declare(strict_types=1);
namespace Orbit;
use Monolog\Logger;
/**
* Module interface
*
* This represents some work that a request->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;
}

View file

@ -60,7 +60,7 @@ class Response
} }
/** /**
* Send data to client * Send response body to client
* *
* @param resource $client * @param resource $client
* @return int|false Number of bytes written * @return int|false Number of bytes written
@ -152,11 +152,21 @@ class Response
* @param int $status * @param int $status
* @return void * @return void
*/ */
public function setStatus($status): void public function setStatus(int $status): void
{ {
$this->status = $status; $this->status = $status;
} }
/**
* Get status
*
* @return int
*/
public function getStatus(): int
{
return $this->status;
}
/** /**
* Set response meta value * Set response meta value
* *
@ -167,4 +177,14 @@ class Response
{ {
$this->meta = $meta; $this->meta = $meta;
} }
/**
* Get meta
*
* @return string Meta value
*/
public function getMeta(): string
{
return $this->meta;
}
} }

View file

@ -16,7 +16,6 @@ use Orbit\Module\Statics;
class Server class Server
{ {
const SCHEME = "gemini"; const SCHEME = "gemini";
const WORLD_READABLE = 0x0004;
public static $version = "0.4"; public static $version = "0.4";
@ -29,7 +28,6 @@ class Server
private $connections = []; // Incoming client connections private $connections = []; // Incoming client connections
private $peers = []; // Client connections to read from private $peers = []; // Client connections to read from
private $metas = []; // Meta data for each client connection private $metas = []; // Meta data for each client connection
private $real_root_dir;
/** /**
* Constructor * Constructor
@ -84,21 +82,17 @@ class Server
/** /**
* Listen and handle incoming requests * Listen and handle incoming requests
* *
* @param mixed $root_dir The root directory from which to serve files
* @return void * @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)) { if (!is_dir($root_dir)) {
throw new \Exception("Error: Root directory '$root_dir' not a directory"); 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'", realpath($root_dir)));
$this->logger->debug(sprintf("Root directory '%s'", $this->real_root_dir));
$server = stream_socket_server( $server = stream_socket_server(
$this->getListenAddress(), $this->getListenAddress(),
@ -201,7 +195,7 @@ class Server
$request = new Request($request_buffer); $request = new Request($request_buffer);
// Respond to client // Respond to client
$response = $this->handleResponse($request); $response = $this->handle($request);
$size = 0; $size = 0;
try { try {
@ -229,7 +223,7 @@ class Server
* @param Request $request * @param Request $request
* @return Response * @return Response
*/ */
public function handleResponse(Request $request): Response public function handle(Request $request): Response
{ {
[$is_valid, $response] = $this->validateRequest($request); [$is_valid, $response] = $this->validateRequest($request);
if ($is_valid === false) { if ($is_valid === false) {
@ -239,22 +233,11 @@ class Server
} }
$statics_module = new Statics($this->config, $this->logger); $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; 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 * Prune any lingering connections
* *

View file

@ -0,0 +1,191 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use PHPUnit\Framework\TestCase;
use Monolog\Logger;
use Orbit\Config;
use Orbit\Module\Statics;
use Orbit\Request;
use Orbit\Response;
final class StaticsTest extends TestCase
{
public function makeObject($config_params = []): Statics
{
$config = new Config();
$config->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');
}
}

View file

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use PHPUnit\Framework\TestCase;
use Monolog\Logger;
use Orbit\Config;
use Orbit\Module;
use Orbit\Request;
use Orbit\Response;
final class ModuleTest extends TestCase
{
public function testConstruct(): void
{
$module = new Module(new Config(), new Logger('foobar'));
$this->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);
}
}