Browse Source

Clean up during testing, add module interface

develop
Jansen Price 5 months ago
parent
commit
a1c3d730ca
9 changed files with 336 additions and 48 deletions
  1. +1
    -0
      .gitignore
  2. +13
    -3
      src/Orbit/Config.php
  3. +13
    -1
      src/Orbit/Module.php
  4. +35
    -19
      src/Orbit/Module/Statics.php
  5. +26
    -0
      src/Orbit/ModuleInterface.php
  6. +22
    -2
      src/Orbit/Response.php
  7. +6
    -23
      src/Orbit/Server.php
  8. +191
    -0
      tests/src/Orbit/Module/StaticsTest.php
  9. +29
    -0
      tests/src/Orbit/ModuleTest.php

+ 1
- 0
.gitignore View File

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

+ 13
- 3
src/Orbit/Config.php View File

@ -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;
}


+ 13
- 1
src/Orbit/Module.php View File

@ -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()];
}
}

+ 35
- 19
src/Orbit/Module/Statics.php View File

@ -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);
}
}

+ 26
- 0
src/Orbit/ModuleInterface.php 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;
}

+ 22
- 2
src/Orbit/Response.php View File

@ -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;
}
}

+ 6
- 23
src/Orbit/Server.php View File

@ -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
*


+ 191
- 0
tests/src/Orbit/Module/StaticsTest.php 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');
}
}

+ 29
- 0
tests/src/Orbit/ModuleTest.php 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);
}
}

Loading…
Cancel
Save