Clean up during testing, add module interface
This commit is contained in:
parent
dadd3b4663
commit
a1c3d730ca
|
@ -1,3 +1,4 @@
|
|||
vendor
|
||||
*.log
|
||||
*.pem
|
||||
.phpunit.result.cache
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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()];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue