Refactor slightly; use strict types and add tests
This commit is contained in:
parent
d509bcea17
commit
dadd3b4663
|
@ -1,9 +1,14 @@
|
|||
<?php
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
use Monolog\Logger;
|
||||
|
||||
/**
|
||||
* Cert
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Cert
|
||||
{
|
||||
public $hostname = '';
|
||||
|
@ -11,14 +16,25 @@ class Cert
|
|||
public $tls_keyfile = '';
|
||||
public $key_passphrase = '';
|
||||
|
||||
public function __construct(Config $config, Logger $logger)
|
||||
/**
|
||||
* __construct
|
||||
*
|
||||
* @param Config $config
|
||||
* @param Logger $logger
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Config $config, ?Logger $logger = null)
|
||||
{
|
||||
$this->hostname = $config->hostname;
|
||||
$this->tls_certfile = $config->tls_certfile;
|
||||
$this->tls_keyfile = $config->tls_keyfile;
|
||||
$this->key_passphrase = $config->key_passphrase;
|
||||
|
||||
$this->logger = $logger;
|
||||
if ($logger !== null) {
|
||||
$this->logger = $logger;
|
||||
} else {
|
||||
$this->logger = new Logger('orbit-cert');
|
||||
}
|
||||
|
||||
if ($config->getIsDevelopmentServer()) {
|
||||
$this->initDevelopment();
|
||||
|
@ -34,7 +50,7 @@ class Cert
|
|||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function initDevelopment()
|
||||
public function initDevelopment(): bool
|
||||
{
|
||||
$this->logger->debug("Initialize cert for development mode.");
|
||||
|
||||
|
@ -71,7 +87,7 @@ class Cert
|
|||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function initProduction()
|
||||
public function initProduction(): bool
|
||||
{
|
||||
$this->logger->debug("Initialize cert for production mode.");
|
||||
|
||||
|
@ -99,10 +115,16 @@ class Cert
|
|||
|
||||
$this->logger->debug(sprintf("Using cert file '%s'", $this->tls_certfile));
|
||||
$this->logger->debug(sprintf("Using key file '%s'", $this->tls_keyfile));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateCert()
|
||||
/**
|
||||
* Generate a self-signed cert
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function generateCert(): void
|
||||
{
|
||||
// Certificate data
|
||||
$dn = [
|
||||
|
@ -157,7 +179,7 @@ class Cert
|
|||
* @param array $san_domains
|
||||
* @return string Filename
|
||||
*/
|
||||
private function createOpenSslConf($san_domains = [])
|
||||
private function createOpenSslConf(array $san_domains = []): string
|
||||
{
|
||||
$san_domains_string = implode(",", $san_domains);
|
||||
|
||||
|
@ -187,7 +209,15 @@ EOS;
|
|||
return $temp_filename;
|
||||
}
|
||||
|
||||
private function ensureDirExists($filename)
|
||||
/**
|
||||
* Ensure directory for a given filename exists
|
||||
*
|
||||
* Will recursively create parent directories if they don't exist
|
||||
*
|
||||
* @param string $filename
|
||||
* @return void
|
||||
*/
|
||||
private function ensureDirExists(string $filename): void
|
||||
{
|
||||
$dir = dirname($filename);
|
||||
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
<?php
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
/**
|
||||
* Config
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Config
|
||||
{
|
||||
public $host = "0.0.0.0";
|
||||
|
@ -21,23 +26,34 @@ class Config
|
|||
|
||||
private $is_development_server = false;
|
||||
|
||||
public function __construct($is_development = false)
|
||||
/**
|
||||
* __construct
|
||||
*
|
||||
* @param bool $is_development
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($is_development_server = false)
|
||||
{
|
||||
$this->setIsDevelopmentServer($is_development);
|
||||
$this->setIsDevelopmentServer($is_development_server);
|
||||
}
|
||||
|
||||
public function setIsDevelopmentServer($is_development_server)
|
||||
public function setIsDevelopmentServer($is_development_server): void
|
||||
{
|
||||
$this->is_development_server = (bool) $is_development_server;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIsDevelopmentServer()
|
||||
public function getIsDevelopmentServer(): bool
|
||||
{
|
||||
return $this->is_development_server;
|
||||
}
|
||||
|
||||
public function readFromIniFile($filename)
|
||||
/**
|
||||
* Read config values from ini file
|
||||
*
|
||||
* @param string $filename Path to ini file
|
||||
* @return void
|
||||
*/
|
||||
public function readFromIniFile(string $filename)
|
||||
{
|
||||
if (!file_exists($filename) || !is_readable($filename)) {
|
||||
throw new \Exception("Cannot read config file '$filename'");
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<?php
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
|
@ -6,9 +6,22 @@ use Monolog\Logger;
|
|||
use Monolog\Formatter\LineFormatter;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
|
||||
/**
|
||||
* Console
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Console extends \Qi_Console_Client
|
||||
{
|
||||
public function execute()
|
||||
public static $under_test = false;
|
||||
|
||||
/**
|
||||
* Execute the console command
|
||||
*
|
||||
* @param Logger $logger
|
||||
* @return int Status code
|
||||
*/
|
||||
public function execute(Logger $logger = null): int
|
||||
{
|
||||
if ($this->_args->get('no-color')) {
|
||||
$this->_terminal->setIsatty(false);
|
||||
|
@ -25,7 +38,9 @@ class Console extends \Qi_Console_Client
|
|||
}
|
||||
|
||||
$config = $this->makeConfig();
|
||||
$logger = $this->makeLogger($config, $this->_args->quiet);
|
||||
if (!$logger) {
|
||||
$logger = $this->makeLogger($config, $this->_args->quiet);
|
||||
}
|
||||
|
||||
if (!$this->_args->quiet) {
|
||||
print "Orbit // Gemini server software\n";
|
||||
|
@ -34,10 +49,20 @@ class Console extends \Qi_Console_Client
|
|||
$cert = new Cert($config, $logger);
|
||||
|
||||
$server = new Server($config, $cert, $logger);
|
||||
$server->listen();
|
||||
|
||||
if (!self::$under_test) {
|
||||
$server->listen();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function makeConfig()
|
||||
/**
|
||||
* Make Config object
|
||||
*
|
||||
* @return Config
|
||||
*/
|
||||
public function makeConfig(): Config
|
||||
{
|
||||
$config = new Config($this->_args->dev);
|
||||
|
||||
|
@ -80,7 +105,14 @@ class Console extends \Qi_Console_Client
|
|||
return $config;
|
||||
}
|
||||
|
||||
public function makeLogger($config, $is_quiet = false)
|
||||
/**
|
||||
* Make Logger object
|
||||
*
|
||||
* @param Config $config
|
||||
* @param bool $is_quiet
|
||||
* @return Logger
|
||||
*/
|
||||
public function makeLogger(Config $config, $is_quiet = false): Logger
|
||||
{
|
||||
$logger = new Logger('orbit');
|
||||
|
||||
|
@ -102,7 +134,12 @@ class Console extends \Qi_Console_Client
|
|||
return $logger;
|
||||
}
|
||||
|
||||
public function makeLogFormatter($is_tty = false)
|
||||
/**
|
||||
* Make a LineFormatter object
|
||||
*
|
||||
* @return LineFormatter
|
||||
*/
|
||||
private function makeLogFormatter($is_tty = false): LineFormatter
|
||||
{
|
||||
$pid = getmypid();
|
||||
|
||||
|
@ -118,12 +155,22 @@ class Console extends \Qi_Console_Client
|
|||
return new LineFormatter($output, 'Y-m-d\TH:i:s');
|
||||
}
|
||||
|
||||
public function showVersion()
|
||||
/**
|
||||
* Print the version of orbit server
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function showVersion(): void
|
||||
{
|
||||
print "Orbit " . Server::$version . "\n";
|
||||
}
|
||||
|
||||
public function showHelp()
|
||||
/**
|
||||
* Print the help message
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function showHelp(): void
|
||||
{
|
||||
$this->showVersion();
|
||||
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
use Monolog\Logger;
|
||||
|
||||
/**
|
||||
* Abstract Module
|
||||
*
|
||||
* This represents some work that a request->response handler can do
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Module
|
||||
{
|
||||
protected $config;
|
||||
protected $logger;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param Config $config
|
||||
* @param Logger $logger
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Config $config, Logger $logger)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit\Module;
|
||||
|
||||
use Orbit\Module;
|
||||
use Orbit\Request;
|
||||
use Orbit\Response;
|
||||
|
||||
/**
|
||||
* Static files server module
|
||||
*
|
||||
* @uses Module
|
||||
* @package Orbit
|
||||
*/
|
||||
class Statics extends Module
|
||||
{
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$resource_path = rtrim($real_root_dir, "/") . $request->path;
|
||||
|
||||
// Check if within the server root
|
||||
// Realpath will translate any '..' in the path
|
||||
$realpath = realpath($resource_path);
|
||||
if ($realpath && strpos($realpath, $real_root_dir) !== 0) {
|
||||
$response->setStatus(Response::STATUS_PERMANENT_FAILURE);
|
||||
$response->setMeta("Invalid location");
|
||||
return [true, $response];
|
||||
}
|
||||
|
||||
if (is_dir($resource_path)) {
|
||||
// If missing the final slash, issue a redirect
|
||||
if ($resource_path[-1] != "/") {
|
||||
$response->setStatus(Response::STATUS_REDIRECT_PERMANENT);
|
||||
$response->setMeta($request->getUrlAppendPath('/'));
|
||||
return [true, $response];
|
||||
}
|
||||
|
||||
// Check if index file exists
|
||||
if (file_exists($resource_path . DIRECTORY_SEPARATOR . $this->config->index_file)) {
|
||||
$resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file;
|
||||
} else {
|
||||
if (!$this->config->enable_directory_index) {
|
||||
$response->setStatus(Response::STATUS_BAD_REQUEST);
|
||||
$response->setMeta('Path not available');
|
||||
return [true, $response];
|
||||
} else {
|
||||
$response->setStatus(Response::STATUS_SUCCESS);
|
||||
$response->setMeta('text/gemini');
|
||||
$response->setBody($this->makeDirectoryIndex($resource_path, $real_root_dir));
|
||||
return [true, $response];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File exists and is world readable
|
||||
if (file_exists($resource_path) && self::isWorldReadble($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];
|
||||
}
|
||||
|
||||
$meta = $this->getCustomMimeFromFileExtension($pathinfo['extension']);
|
||||
if (!$meta) {
|
||||
$meta = mime_content_type($resource_path);
|
||||
}
|
||||
$response->setMeta($meta);
|
||||
$response->setStaticFile($resource_path);
|
||||
} else {
|
||||
$response->setStatus(Response::STATUS_NOT_FOUND);
|
||||
$response->setMeta('Not found!');
|
||||
}
|
||||
|
||||
return [true, $response];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mime type from file extension for custom types
|
||||
*
|
||||
* @param string $extension
|
||||
* @return string
|
||||
*/
|
||||
public function getCustomMimeFromFileExtension($extension): string
|
||||
{
|
||||
switch ($extension) {
|
||||
case 'gmi':
|
||||
case 'gemini':
|
||||
return 'text/gemini';
|
||||
break;
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return 'text/gemini';
|
||||
break;
|
||||
case 'ans':
|
||||
case 'ansi':
|
||||
return 'text/x-ansi';
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a directory index suitable as response content
|
||||
*
|
||||
* @param string $path Current path
|
||||
* @param string $root Root path on disk of the server
|
||||
* @return string
|
||||
*/
|
||||
public function makeDirectoryIndex($path, $root): string
|
||||
{
|
||||
$files = glob($path . "*");
|
||||
|
||||
$body = "# Directory listing " . str_replace($root, '', $path) . "\n\n";
|
||||
$body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n";
|
||||
|
||||
foreach ($files as $file) {
|
||||
$relative_path = str_replace($path, '', $file);
|
||||
|
||||
$is_dir = false;
|
||||
if (is_dir($file)) {
|
||||
$is_dir = true;
|
||||
$size = '';
|
||||
} else {
|
||||
$size = filesize($file);
|
||||
}
|
||||
|
||||
$body .= sprintf(
|
||||
"=> %s%s %s%s%s\n",
|
||||
urlencode($relative_path),
|
||||
($is_dir ? '/' : ''),
|
||||
$relative_path,
|
||||
($is_dir ? '/' : ''),
|
||||
($size ? " ($size)" : '')
|
||||
);
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
<?php
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
/**
|
||||
* Request
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Request
|
||||
{
|
||||
public $url = '';
|
||||
|
@ -14,13 +19,19 @@ class Request
|
|||
public $query;
|
||||
public $fragment;
|
||||
|
||||
public function __construct($request_input)
|
||||
/**
|
||||
* Construct a request object
|
||||
*
|
||||
* @param string $request_input Gemini request line (URL)
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(string $request_input)
|
||||
{
|
||||
$this->url = trim($request_input);
|
||||
$data = parse_url($this->url);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$this->{$key} = urldecode($value);
|
||||
$this->{$key} = urldecode((string) $value);
|
||||
}
|
||||
|
||||
// If scheme is missing, infer as default scheme
|
||||
|
@ -29,7 +40,12 @@ class Request
|
|||
}
|
||||
}
|
||||
|
||||
public function getUrlAppendPath($text)
|
||||
/**
|
||||
* Get a new URL with some text appended to the path
|
||||
*
|
||||
* @return string New URL
|
||||
*/
|
||||
public function getUrlAppendPath(string $text): string
|
||||
{
|
||||
return $this->scheme . '://'
|
||||
. ($this->user ? $this->user : '')
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
<?php
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
/**
|
||||
* Response
|
||||
*
|
||||
* See the following website for description of the response spec
|
||||
* https://gemini.circumlunar.space/docs/specification.html
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Response
|
||||
{
|
||||
const STATUS_INPUT = 10;
|
||||
|
@ -28,32 +36,63 @@ class Response
|
|||
public $body = "";
|
||||
public $filepath;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $status
|
||||
* @param string $meta
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($status = "", $meta = "")
|
||||
{
|
||||
$this->status = $status;
|
||||
$this->meta = $meta;
|
||||
}
|
||||
|
||||
public function getHeader()
|
||||
/**
|
||||
* Get a header to be sent to client
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getHeader(): string
|
||||
{
|
||||
return sprintf("%s %s\r\n", $this->status, $this->meta);
|
||||
}
|
||||
|
||||
public function send($client)
|
||||
/**
|
||||
* Send data to client
|
||||
*
|
||||
* @param resource $client
|
||||
* @return int|false Number of bytes written
|
||||
*/
|
||||
public function send($client): int
|
||||
{
|
||||
if (!is_resource($client)) {
|
||||
throw new \Exception("Invalid resource to write to");
|
||||
}
|
||||
$result = fwrite($client, $this->getHeader());
|
||||
|
||||
if (!$result) {
|
||||
return false;
|
||||
throw new \Exception("Failed to write to client");
|
||||
}
|
||||
|
||||
if ($this->filepath) {
|
||||
$size = filesize($this->filepath);
|
||||
$fp = fopen($this->filepath, "rb");
|
||||
if (false === $fp) {
|
||||
if (is_dir($this->filepath)) {
|
||||
throw new \Exception("Cannot serve directory '{$this->filepath}'");
|
||||
}
|
||||
|
||||
if (!file_exists($this->filepath)) {
|
||||
throw new \Exception("Error reading file '{$this->filepath}'");
|
||||
}
|
||||
|
||||
$fp = fopen($this->filepath, "rb");
|
||||
|
||||
if (false === $fp) {
|
||||
throw new \Exception("Cannot read file '{$this->filepath}'");
|
||||
}
|
||||
|
||||
$size = filesize($this->filepath);
|
||||
|
||||
$result = 1;
|
||||
while (!feof($fp) && $result) {
|
||||
// If the client cancels, bail out before trying large files
|
||||
|
@ -69,35 +108,63 @@ class Response
|
|||
}
|
||||
}
|
||||
|
||||
public function setBody($body = '')
|
||||
/**
|
||||
* Set response body
|
||||
*
|
||||
* @param string $body
|
||||
* @return void
|
||||
*/
|
||||
public function setBody(string $body = ''): void
|
||||
{
|
||||
$this->body = $body;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBody()
|
||||
/**
|
||||
* Get response body
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getBody(): string
|
||||
{
|
||||
if ($this->filepath) {
|
||||
$this->body = file_get_contents($this->filepath);
|
||||
}
|
||||
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function setStaticFile($filepath)
|
||||
/**
|
||||
* Set static file
|
||||
*
|
||||
* Indicates this response should consist of a static file on disk
|
||||
*
|
||||
* @param mixed $filepath
|
||||
* @return void
|
||||
*/
|
||||
public function setStaticFile(string $filepath): void
|
||||
{
|
||||
$this->filepath = $filepath;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setStatus($status)
|
||||
/**
|
||||
* Set response status
|
||||
*
|
||||
* @param int $status
|
||||
* @return void
|
||||
*/
|
||||
public function setStatus($status): void
|
||||
{
|
||||
$this->status = $status;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMeta($meta)
|
||||
/**
|
||||
* Set response meta value
|
||||
*
|
||||
* @param string $meta
|
||||
* @return void
|
||||
*/
|
||||
public function setMeta($meta): void
|
||||
{
|
||||
$this->meta = $meta;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<?php
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
|
@ -6,11 +6,19 @@ use Monolog\Logger;
|
|||
use Monolog\Handler\StreamHandler;
|
||||
use ForceUTF8\Encoding;
|
||||
|
||||
use Orbit\Module\Statics;
|
||||
|
||||
/**
|
||||
* Server
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Server
|
||||
{
|
||||
const SCHEME = "gemini";
|
||||
const WORLD_READABLE = 0x0004;
|
||||
|
||||
public static $version = "0.3";
|
||||
public static $version = "0.4";
|
||||
|
||||
public $config;
|
||||
public $cert;
|
||||
|
@ -23,47 +31,63 @@ class Server
|
|||
private $metas = []; // Meta data for each client connection
|
||||
private $real_root_dir;
|
||||
|
||||
public function __construct(Config $config = null, Cert $cert = null, Logger $logger = null)
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param Config $config
|
||||
* @param Cert $cert
|
||||
* @param Logger $logger
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Config $config, ?Cert $cert = null, ?Logger $logger = null)
|
||||
{
|
||||
if ($config == null) {
|
||||
$this->config = new Config();
|
||||
} else {
|
||||
$this->config = $config;
|
||||
}
|
||||
$this->config = $config;
|
||||
|
||||
if ($logger !== null) {
|
||||
$this->setLogger($logger);
|
||||
} else {
|
||||
$this->getLogger();
|
||||
}
|
||||
|
||||
if ($cert == null) {
|
||||
$this->cert = new Cert($config, $this->getLogger());
|
||||
} else {
|
||||
$this->cert = $cert;
|
||||
}
|
||||
|
||||
$this->timeout = ini_get("default_socket_timeout");
|
||||
|
||||
$this->ssl_context = $this->createSslContext();
|
||||
}
|
||||
|
||||
public function setLogger(Logger $logger)
|
||||
{
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function getLogger()
|
||||
{
|
||||
if (!$this->logger) {
|
||||
$this->logger = new Logger('orbit');
|
||||
$this->logger->pushHandler(new StreamHandler($this->config->log_file, Logger::INFO));
|
||||
}
|
||||
|
||||
if ($cert == null) {
|
||||
$this->cert = new Cert($this->config, $this->getLogger());
|
||||
} else {
|
||||
$this->cert = $cert;
|
||||
}
|
||||
|
||||
$this->timeout = (int) ini_get("default_socket_timeout");
|
||||
|
||||
$this->ssl_context = $this->createSslContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set logger object
|
||||
*
|
||||
* @param Logger $logger
|
||||
*/
|
||||
public function setLogger(Logger $logger): void
|
||||
{
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logger
|
||||
*
|
||||
* @return Logger
|
||||
*/
|
||||
public function getLogger(): Logger
|
||||
{
|
||||
return $this->logger;
|
||||
}
|
||||
|
||||
public function listen($root_dir = null)
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if (null == $root_dir) {
|
||||
$root_dir = $this->config->root_dir;
|
||||
|
@ -78,7 +102,8 @@ class Server
|
|||
|
||||
$server = stream_socket_server(
|
||||
$this->getListenAddress(),
|
||||
$errno, $errstr,
|
||||
$errno,
|
||||
$errstr,
|
||||
STREAM_SERVER_BIND|STREAM_SERVER_LISTEN,
|
||||
$this->ssl_context
|
||||
);
|
||||
|
@ -115,16 +140,28 @@ class Server
|
|||
}
|
||||
}
|
||||
|
||||
public function intakeConnections($server)
|
||||
/**
|
||||
* Intake connections
|
||||
*
|
||||
* @param resource $server
|
||||
* @return bool
|
||||
*/
|
||||
public function intakeConnections($server): bool
|
||||
{
|
||||
if (!is_resource($server)) {
|
||||
throw new \Exception("Stream server must be resource object");
|
||||
}
|
||||
|
||||
set_error_handler([$this, 'onWarning']);
|
||||
# onWarning is added here to swallow up the `timeout` warning
|
||||
$client = stream_socket_accept($server, $this->timeout, $peer);
|
||||
restore_error_handler();
|
||||
|
||||
if (!$client) {
|
||||
# If the listener times out, it will run this and then start
|
||||
# listening again
|
||||
$this->pruneExpiredConnections();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
$time = ['start' => microtime(true)];
|
||||
|
@ -133,9 +170,17 @@ class Server
|
|||
$this->logger->debug("$peer Accepted", $meta);
|
||||
$this->connections[$peer] = $client;
|
||||
$this->metas[$peer] = ["input" => "", "time" => $time];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function handlePeerConnection($client)
|
||||
/**
|
||||
* Handle a single peer connection (client request)
|
||||
*
|
||||
* @param resource $client
|
||||
* @return void
|
||||
*/
|
||||
public function handlePeerConnection($client): void
|
||||
{
|
||||
$peer = stream_socket_get_name($client, true);
|
||||
|
||||
|
@ -149,7 +194,7 @@ class Server
|
|||
|| strpos($this->metas[$peer]['input'], "\r\n") !== false
|
||||
) {
|
||||
$request_buffer = $this->metas[$peer]['input'];
|
||||
//print($this->hexView($request_buffer));
|
||||
//print(Util::hexView($request_buffer));
|
||||
//print("Length: " . mb_strlen($request_buffer) . "\n");
|
||||
$this->logger->info("REQ: $request_buffer", ['client' => $peer]);
|
||||
|
||||
|
@ -157,15 +202,20 @@ class Server
|
|||
|
||||
// Respond to client
|
||||
$response = $this->handleResponse($request);
|
||||
$size = $response->send($client);
|
||||
if ($size !== false) {
|
||||
$time_end = microtime(true);
|
||||
$this->logger->debug(
|
||||
"RSP: " . trim($response->getHeader()),
|
||||
['size' => $size, 'time' => $time_end - $this->metas[$peer]['time']['start']]
|
||||
);
|
||||
$size = 0;
|
||||
|
||||
try {
|
||||
$size = $response->send($client);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
}
|
||||
|
||||
$time_end = microtime(true);
|
||||
$this->logger->debug(
|
||||
"RSP: " . trim($response->getHeader()),
|
||||
['size' => $size, 'time' => $time_end - $this->metas[$peer]['time']['start']]
|
||||
);
|
||||
|
||||
fclose($client);
|
||||
$this->logger->debug("$peer Closed");
|
||||
unset($this->connections[$peer]);
|
||||
|
@ -173,79 +223,47 @@ class Server
|
|||
}
|
||||
}
|
||||
|
||||
public function handleResponse($request)
|
||||
/**
|
||||
* Handle creating a response and sending it to a client
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function handleResponse(Request $request): Response
|
||||
{
|
||||
list($is_valid, $response) = $this->validateRequest($request);
|
||||
|
||||
[$is_valid, $response] = $this->validateRequest($request);
|
||||
if ($is_valid === false) {
|
||||
// If not valid, just return the error response object created by
|
||||
// the validator
|
||||
return $response;
|
||||
}
|
||||
|
||||
$resource_path = rtrim($this->real_root_dir, "/") . $request->path;
|
||||
|
||||
// Check if within the server root
|
||||
// Realpath will translate any '..' in the path
|
||||
$realpath = realpath($resource_path);
|
||||
if ($realpath && strpos($realpath, $this->real_root_dir) !== 0) {
|
||||
$response->setStatus(Response::STATUS_PERMANENT_FAILURE);
|
||||
$response->setMeta("Invalid location");
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (is_dir($resource_path)) {
|
||||
// If missing the final slash, issue a redirect
|
||||
if ($resource_path[-1] != "/") {
|
||||
$response->setStatus(Response::STATUS_REDIRECT_PERMANENT);
|
||||
$response->setMeta($request->getUrlAppendPath('/'));
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Check if index file exists
|
||||
if (file_exists($resource_path . DIRECTORY_SEPARATOR . $this->config->index_file)) {
|
||||
$resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file;
|
||||
} else {
|
||||
if (!$this->config->enable_directory_index) {
|
||||
$response->setStatus(Response::STATUS_BAD_REQUEST);
|
||||
$response->setMeta('Path not available');
|
||||
return $response;
|
||||
} else {
|
||||
$response->setStatus(Response::STATUS_SUCCESS);
|
||||
$response->setMeta('text/gemini');
|
||||
$response->setBody($this->makeDirectoryIndex($resource_path, $dir));
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File exists and is world readable
|
||||
if (file_exists($resource_path) && (fileperms($resource_path) & 0x0004)) {
|
||||
$response->setStatus(Response::STATUS_SUCCESS);
|
||||
|
||||
$pathinfo = pathinfo($resource_path);
|
||||
if (!isset($pathinfo['extension'])) {
|
||||
$response->setStatus(Response::STATUS_TEMPORARY_FAILURE);
|
||||
$response->setMeta('Error reading resource');
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($pathinfo['extension'] == 'gmi' || $pathinfo['extension'] == 'gemini') {
|
||||
$response->setMeta('text/gemini');
|
||||
} elseif ($pathinfo['extension'] == 'md' || $pathinfo['extension'] == 'markdown') {
|
||||
$response->setMeta('text/gemini');
|
||||
} else {
|
||||
$response->setMeta(mime_content_type($resource_path));
|
||||
}
|
||||
|
||||
$response->setStaticFile($resource_path);
|
||||
} else {
|
||||
$response->setStatus(Response::STATUS_NOT_FOUND);
|
||||
$response->setMeta('Not found!');
|
||||
}
|
||||
$statics_module = new Statics($this->config, $this->logger);
|
||||
[$success, $response] = $statics_module->handleResponse($request, $response, $this->real_root_dir);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function pruneExpiredConnections()
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* Remove any connections that are still 'open' but have been open past a
|
||||
* certain threshold timeout
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function pruneExpiredConnections(): void
|
||||
{
|
||||
$now = microtime(true);
|
||||
foreach ($this->metas as $peer => $meta) {
|
||||
|
@ -260,7 +278,16 @@ class Server
|
|||
}
|
||||
}
|
||||
|
||||
public function validateRequest($request)
|
||||
/**
|
||||
* Validate a request
|
||||
*
|
||||
* Will return an tuple (array with two values). The first value is whether
|
||||
* it passed the validation logic. The second value is the Response object.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return array [bool, Response]
|
||||
*/
|
||||
public function validateRequest(Request $request): array
|
||||
{
|
||||
$response = new Response();
|
||||
|
||||
|
@ -279,7 +306,9 @@ class Server
|
|||
}
|
||||
|
||||
// Valid URL must use targeted hostname
|
||||
if ($request->host != "127.0.0.1" && $request->host != "localhost" && $request->host != $this->config->hostname) {
|
||||
if ($request->host != "127.0.0.1" && $request->host != "localhost"
|
||||
&& $request->host != $this->config->hostname
|
||||
) {
|
||||
$response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED);
|
||||
$response->setMeta("Proxy error - invalid host");
|
||||
return [false, $response];
|
||||
|
@ -310,59 +339,44 @@ class Server
|
|||
return [true, $response];
|
||||
}
|
||||
|
||||
public function log($level, $message, $context = [])
|
||||
/**
|
||||
* Special error handler
|
||||
*
|
||||
* For warnings that occur when attempting to start server stream
|
||||
*
|
||||
* @param int $errno
|
||||
* @param string $errstr
|
||||
* @return bool
|
||||
*/
|
||||
public function onWarning($errno, $errstr): bool
|
||||
{
|
||||
$this->getLogger()->log($level, $message, $context);
|
||||
}
|
||||
|
||||
public function onWarning($id, $message)
|
||||
{
|
||||
if (strpos($message, "timed out") !== false) {
|
||||
if (strpos($errstr, "timed out") !== false) {
|
||||
// Do nothing
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Something else happened.
|
||||
$error = sprintf("Error while accepting connection %s: %s", $id, $message);
|
||||
$this->logger->error($error);
|
||||
$message = sprintf("Error while accepting connection %s: %s", $errno, $errstr);
|
||||
$this->logger->error($message);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getListenAddress()
|
||||
/**
|
||||
* Get the listen address for the stream_server
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getListenAddress(): string
|
||||
{
|
||||
return sprintf('tls://%s:%s', $this->config->host, $this->config->port);
|
||||
}
|
||||
|
||||
public function makeDirectoryIndex($path, $root)
|
||||
{
|
||||
$files = glob($path . "*");
|
||||
|
||||
$body = "# Directory listing " . str_replace($root, '', $path) . "\n\n";
|
||||
$body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n";
|
||||
|
||||
foreach ($files as $file) {
|
||||
$relative_path = str_replace($path, '', $file);
|
||||
|
||||
$is_dir = false;
|
||||
if (is_dir($file)) {
|
||||
$is_dir = true;
|
||||
$size = '';
|
||||
} else {
|
||||
$size = filesize($file);
|
||||
}
|
||||
|
||||
$body .= sprintf(
|
||||
"=> %s%s %s%s%s\n",
|
||||
urlencode($relative_path),
|
||||
($is_dir ? '/' : ''),
|
||||
$relative_path,
|
||||
($is_dir ? '/' : ''),
|
||||
($size ? " ($size)" : '')
|
||||
);
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSL context for stream server
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function createSslContext()
|
||||
{
|
||||
$context = stream_context_create();
|
||||
|
@ -377,41 +391,4 @@ class Server
|
|||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* View hex chars of string
|
||||
*
|
||||
* Outputs a listing of hexidecimal values in 16 byte rows
|
||||
*
|
||||
* @param mixed $text Input text
|
||||
* @return string
|
||||
*/
|
||||
private function hexView($text)
|
||||
{
|
||||
$num = 16;
|
||||
$outStr = '';
|
||||
$printableChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
. 'abcdefghijklmnopqrstuvwxyz'
|
||||
. '0123456789~!@#$%^&*()_+-={}|[]\:";\'<>?,./';
|
||||
|
||||
$charCount = strlen($text);
|
||||
for ($i = 0; $i < $charCount; $i += $num) {
|
||||
$printStr = '';
|
||||
for ($j = 0; $j < $num; $j++) {
|
||||
$char = substr($text, $i+$j, 1);
|
||||
|
||||
$outStr .= sprintf("%02X", ord($char)) . " ";
|
||||
|
||||
if (ord($char) >= 32 && ord($char) < 127) {
|
||||
$printStr .= $char;
|
||||
} else {
|
||||
$printStr .= ".";
|
||||
}
|
||||
}
|
||||
|
||||
$outStr .= " | " . $printStr . "\n";
|
||||
}
|
||||
|
||||
return $outStr;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
class Util
|
||||
{
|
||||
/**
|
||||
* View hex chars of string
|
||||
*
|
||||
* Outputs a listing of hexidecimal values in 16 byte rows
|
||||
*
|
||||
* @param string $text Input text
|
||||
* @return string
|
||||
*/
|
||||
public static function hexView(string $text): string
|
||||
{
|
||||
$width = 16;
|
||||
$outStr = '';
|
||||
|
||||
// Specifically not using mb_strlen to get every byte
|
||||
$charCount = strlen($text);
|
||||
for ($i = 0; $i < $charCount; $i += $width) {
|
||||
$printStr = '';
|
||||
for ($j = 0; $j < $width; $j++) {
|
||||
$char = (string) substr($text, $i+$j, 1);
|
||||
|
||||
$outStr .= sprintf("%02X", ord($char)) . " ";
|
||||
|
||||
if (ord($char) >= 32 && ord($char) < 127) {
|
||||
$printStr .= $char;
|
||||
} else {
|
||||
$printStr .= ".";
|
||||
}
|
||||
}
|
||||
|
||||
$outStr .= " | " . $printStr . "\n";
|
||||
}
|
||||
|
||||
return $outStr;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
|
||||
backupGlobals="false"
|
||||
backupStaticAttributes="false"
|
||||
colors="true"
|
||||
convertErrorsToExceptions="true"
|
||||
convertNoticesToExceptions="true"
|
||||
convertWarningsToExceptions="true"
|
||||
bootstrap="../vendor/autoload.php"
|
||||
>
|
||||
<coverage processUncoveredFiles="true">
|
||||
<include>
|
||||
<directory>../src</directory>
|
||||
</include>
|
||||
</coverage>
|
||||
<testsuites>
|
||||
<testsuite name="Unit Tests">
|
||||
<directory>.</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
|
@ -0,0 +1,188 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use Orbit\Cert;
|
||||
use Orbit\Config;
|
||||
use Monolog\Logger;
|
||||
|
||||
final class CertTest extends TestCase
|
||||
{
|
||||
public function makeTestLogger(): Logger
|
||||
{
|
||||
$logger = new Logger('test-orbit');
|
||||
$logger->pushHandler(new \Monolog\Handler\TestHandler());
|
||||
|
||||
return $logger;
|
||||
}
|
||||
|
||||
public function getTestLogRecords($logger): array
|
||||
{
|
||||
return $logger->getHandlers()[0]->getRecords();
|
||||
}
|
||||
|
||||
public function getTestLogMessages($logger): array
|
||||
{
|
||||
$messages = [];
|
||||
foreach ($this->getTestLogRecords($logger) as $message) {
|
||||
$messages[] = $message['level_name'] . ": " . $message['message'];
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public function testConstructDev(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
$cert = new Cert($config, $logger);
|
||||
$this->assertInstanceOf(Cert::class, $cert);
|
||||
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertEquals(3, count($messages));
|
||||
$this->assertStringContainsString('development mode', $messages[0]);
|
||||
$this->assertStringContainsString('Generating new cert file', $messages[1]);
|
||||
$this->assertStringContainsString('Generating new key file', $messages[2]);
|
||||
}
|
||||
|
||||
public function testConstructDevExistingCertFile(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
$cert = new Cert($config, $logger);
|
||||
$this->assertInstanceOf(Cert::class, $cert);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('development mode', $messages[0]);
|
||||
$this->assertStringContainsString('Generating new cert file', $messages[1]);
|
||||
$this->assertStringContainsString('Generating new key file', $messages[2]);
|
||||
|
||||
$logger = $this->makeTestLogger();
|
||||
$cert = new Cert($config, $logger);
|
||||
$this->assertInstanceOf(Cert::class, $cert);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('Using existing cert file', $messages[1]);
|
||||
$this->assertStringContainsString('Using existing key file', $messages[2]);
|
||||
}
|
||||
|
||||
public function testConstructDevOverwriteCertWarning(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
@mkdir('certs');
|
||||
file_put_contents('certs/localhost.cert.pem', '####');
|
||||
$cert = new Cert($config, $logger);
|
||||
$this->assertInstanceOf(Cert::class, $cert);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('May overwrite existing cert file', $messages[3]);
|
||||
}
|
||||
|
||||
public function testConstructDevSetCertFile(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$config->tls_certfile = 'certs/subd/wahoo.cert.pem';
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
$cert = new Cert($config, $logger);
|
||||
$this->assertInstanceOf(Cert::class, $cert);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('certs/subd/wahoo.cert.pem', $messages[1]);
|
||||
@unlink("certs/subd/wahoo.cert.pem");
|
||||
}
|
||||
|
||||
public function testConstructDevSetKeyFile(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$config->tls_keyfile = 'certs/wahoo.key.pem';
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
$cert = new Cert($config, $logger);
|
||||
$this->assertInstanceOf(Cert::class, $cert);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('certs/wahoo.key.pem', $messages[2]);
|
||||
@unlink("certs/wahoo.key.pem");
|
||||
}
|
||||
|
||||
public function testConstructDevFilesUsingHostname(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$config->hostname = 'particle';
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
$cert = new Cert($config, $logger);
|
||||
$this->assertInstanceOf(Cert::class, $cert);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('certs/particle.cert.pem', $messages[1]);
|
||||
$this->assertStringContainsString('certs/particle.key.pem', $messages[2]);
|
||||
@unlink("certs/particle.cert.pem");
|
||||
@unlink("certs/particle.key.pem");
|
||||
}
|
||||
|
||||
public function testConstructDevOverwriteKeyWarning(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
@mkdir('certs');
|
||||
file_put_contents('certs/localhost.key.pem', '####');
|
||||
$cert = new Cert($config, $logger);
|
||||
$this->assertInstanceOf(Cert::class, $cert);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('May overwrite existing key file', $messages[3]);
|
||||
}
|
||||
|
||||
public function testConstructProd(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage("Missing required cert file");
|
||||
|
||||
$config = new Config();
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
$cert = new Cert($config, $logger);
|
||||
}
|
||||
|
||||
public function testConstructProdDefinedButMissingFiles(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage("does not exist or is not readable");
|
||||
|
||||
$config = new Config();
|
||||
$config->tls_certfile = 'localhost.cert.pem';
|
||||
$config->tls_keyfile = 'localhost.key.pem';
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
$cert = new Cert($config, $logger);
|
||||
}
|
||||
|
||||
public function testConstructProdDefinedFiles(): void
|
||||
{
|
||||
// first run in development mode to auto-generate certs
|
||||
$config = new Config(true);
|
||||
$logger = $this->makeTestLogger();
|
||||
$cert = new Cert($config, $logger);
|
||||
|
||||
// now run in prod mode to use those cert files
|
||||
$config = new Config();
|
||||
$config->tls_certfile = 'certs/localhost.cert.pem';
|
||||
$config->tls_keyfile = 'certs/localhost.key.pem';
|
||||
$logger = $this->makeTestLogger();
|
||||
|
||||
$cert = new Cert($config, $logger);
|
||||
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('Using cert file', $messages[1]);
|
||||
$this->assertStringContainsString('Using key file', $messages[2]);
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
@unlink("certs/localhost.cert.pem");
|
||||
@unlink("certs/localhost.key.pem");
|
||||
@rmdir("certs/subd");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use Orbit\Config;
|
||||
|
||||
final class ConfigTest extends TestCase
|
||||
{
|
||||
public function testConstruct(): void
|
||||
{
|
||||
$config = new Config();
|
||||
$this->assertInstanceOf(Config::class, $config);
|
||||
}
|
||||
|
||||
public function testConstructDev(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$this->assertInstanceOf(Config::class, $config);
|
||||
$this->assertTrue($config->getIsDevelopmentServer());
|
||||
}
|
||||
|
||||
public function testConstructDevFromTruthyValue(): void
|
||||
{
|
||||
$config = new Config("yes");
|
||||
$this->assertInstanceOf(Config::class, $config);
|
||||
$this->assertTrue($config->getIsDevelopmentServer());
|
||||
}
|
||||
|
||||
public function testReadFromIniFile(): void
|
||||
{
|
||||
$data = "hostname=fabricate.dev";
|
||||
file_put_contents('test.ini', $data);
|
||||
|
||||
$config = new Config();
|
||||
$config->readFromIniFile('test.ini');
|
||||
$this->assertSame('fabricate.dev', $config->hostname);
|
||||
|
||||
@unlink('test.ini');
|
||||
}
|
||||
|
||||
public function testReadFromIniNonFile(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Cannot read config file');
|
||||
|
||||
$config = new Config();
|
||||
$config->readFromIniFile('nonfile.ini');
|
||||
}
|
||||
|
||||
public function testReadFromIniAllPossibleKeys(): void
|
||||
{
|
||||
$data = <<<EOF
|
||||
host=1.1.1.1
|
||||
port=1988
|
||||
hostname=beatles.org
|
||||
tls_certfile=1212
|
||||
tls_keyfile=3434
|
||||
keypassphrase=strawberry
|
||||
log_file=xyz.log
|
||||
log_level=cherry
|
||||
root_dir=blueberry
|
||||
index_file=led.gmi
|
||||
enable_directory_index=0
|
||||
EOF;
|
||||
file_put_contents('test.ini', $data);
|
||||
|
||||
$config = new Config();
|
||||
$config->readFromIniFile('test.ini');
|
||||
$this->assertSame('1.1.1.1', $config->host);
|
||||
$this->assertSame('1988', $config->port);
|
||||
$this->assertSame('beatles.org', $config->hostname);
|
||||
$this->assertSame('1212', $config->tls_certfile);
|
||||
$this->assertSame('3434', $config->tls_keyfile);
|
||||
$this->assertSame('strawberry', $config->keypassphrase);
|
||||
$this->assertSame('xyz.log', $config->log_file);
|
||||
$this->assertSame('cherry', $config->log_level);
|
||||
$this->assertSame('blueberry', $config->root_dir);
|
||||
$this->assertSame('led.gmi', $config->index_file);
|
||||
$this->assertSame('0', $config->enable_directory_index);
|
||||
|
||||
@unlink('test.ini');
|
||||
}
|
||||
|
||||
public function testReadFromIniWithInvalidKey(): void
|
||||
{
|
||||
$data = <<<EOF
|
||||
invalid_key=true
|
||||
EOF;
|
||||
file_put_contents('test.ini', $data);
|
||||
|
||||
$config = new Config();
|
||||
$config->readFromIniFile('test.ini');
|
||||
$this->assertFalse(property_exists($config, 'invalid_key'));
|
||||
|
||||
@unlink('test.ini');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use Monolog\Logger;
|
||||
use Orbit\Console;
|
||||
use Orbit\Config;
|
||||
|
||||
final class ConsoleTest extends TestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
Console::$under_test = true;
|
||||
}
|
||||
|
||||
public function makeTestLogger(): Logger
|
||||
{
|
||||
$logger = new Logger('test-orbit');
|
||||
$logger->pushHandler(new \Monolog\Handler\TestHandler());
|
||||
|
||||
return $logger;
|
||||
}
|
||||
|
||||
public function getTestLogRecords($logger): array
|
||||
{
|
||||
return $logger->getHandlers()[0]->getRecords();
|
||||
}
|
||||
|
||||
public function getTestLogMessages($logger): array
|
||||
{
|
||||
$messages = [];
|
||||
foreach ($this->getTestLogRecords($logger) as $message) {
|
||||
$messages[] = $message['level_name'] . ": " . $message['message'];
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public function makeConsole($argv = []): Console
|
||||
{
|
||||
$args = new \Qi_Console_ArgV(
|
||||
$argv,
|
||||
[
|
||||
'config|c:' => 'Use specified config file (.ini) for configuration',
|
||||
'host:' => 'Set host/ip address to listen on (default 0.0.0.0)',
|
||||
'port|p:' => 'Set port to listen on (default 1965)',
|
||||
'hostname:' => 'Set hostname of server (default localhost)',
|
||||
'tls-cert:' => 'Set cert PEM file to use (default null)',
|
||||
'tls-key:' => 'Set private key PEM file to use (default null)',
|
||||
'root-dir:' => 'Set the file root directory',
|
||||
'log:' => 'Set log filename (default orbit.log)',
|
||||
'dev' => 'Allow developer server functions (default false)',
|
||||
'help|h' => 'Show help',
|
||||
'verbose|v' => 'Include more verbose output',
|
||||
'quiet|q' => 'Print less messages',
|
||||
'no-color' => 'Don\'t use color output',
|
||||
'version' => 'Show version and exit',
|
||||
]
|
||||
);
|
||||
$terminal = new \Qi_Console_Terminal();
|
||||
return new Console($args, $terminal);
|
||||
}
|
||||
|
||||
public function testConstruct(): void
|
||||
{
|
||||
$args = new \Qi_Console_ArgV([]);
|
||||
$terminal = new \Qi_Console_Terminal();
|
||||
$console = new Console($args, $terminal);
|
||||
$this->assertInstanceOf(Console::class, $console);
|
||||
}
|
||||
|
||||
public function testExecuteNocolor(): void
|
||||
{
|
||||
$console = $this->makeConsole(['p', '--no-color', '--dev']);
|
||||
$logger = $this->makeTestLogger();
|
||||
ob_start();
|
||||
$status = $console->execute($logger);
|
||||
$output = ob_get_contents();
|
||||
ob_get_clean();
|
||||
|
||||
$this->assertStringContainsString('Orbit // Gemini', $output);
|
||||
}
|
||||
|
||||
public function testExecuteVersion(): void
|
||||
{
|
||||
$console = $this->makeConsole(['p', '--version', '--dev']);
|
||||
$logger = $this->makeTestLogger();
|
||||
ob_start();
|
||||
$status = $console->execute($logger);
|
||||
$output = ob_get_contents();
|
||||
ob_get_clean();
|
||||
|
||||
$this->assertStringContainsString('Orbit 0.4', $output);
|
||||
}
|
||||
|
||||
public function testExecuteHelp(): void
|
||||
{
|
||||
$console = $this->makeConsole(['p', '--help', '--dev']);
|
||||
$logger = $this->makeTestLogger();
|
||||
ob_start();
|
||||
$status = $console->execute($logger);
|
||||
$output = ob_get_contents();
|
||||
ob_get_clean();
|
||||
|
||||
$this->assertStringContainsString('Orbit 0.4', $output);
|
||||
}
|
||||
|
||||
public function testMakeConfigWithConfig(): void
|
||||
{
|
||||
$data = 'host=rainbow.muffin';
|
||||
file_put_contents('test.ini', $data);
|
||||
|
||||
$console = $this->makeConsole(['p', '--config', 'test.ini', '--dev']);
|
||||
$config = $console->makeConfig();
|
||||
$this->assertSame('rainbow.muffin', $config->host);
|
||||
@unlink('test.ini');
|
||||
}
|
||||
|
||||
public function testMakeConfigSetValues(): void
|
||||
{
|
||||
$args = [
|
||||
'p', '--host=a', '--port=b', '--hostname=c', '--log=d',
|
||||
'--verbose', '--root-dir=e', '--tls-cert=f', '--tls-key=g'
|
||||
];
|
||||
$console = $this->makeConsole($args);
|
||||
$config = $console->makeConfig();
|
||||
$this->assertSame('a', $config->host);
|
||||
$this->assertSame('b', $config->port);
|
||||
$this->assertSame('c', $config->hostname);
|
||||
$this->assertSame('d', $config->log_file);
|
||||
$this->assertsame(100, $config->log_level);
|
||||
$this->assertSame('e', $config->root_dir);
|
||||
$this->assertSame('f', $config->tls_certfile);
|
||||
$this->assertSame('g', $config->tls_keyfile);
|
||||
}
|
||||
|
||||
public function testMakeLogger(): void
|
||||
{
|
||||
$config = new Config('dev');
|
||||
$console = $this->makeConsole([]);
|
||||
$logger = $console->makeLogger($config);
|
||||
$this->assertInstanceOf(Logger::class, $logger);
|
||||
// There should be two handlers attached
|
||||
$this->assertEquals(2, count($logger->getHandlers()));
|
||||
}
|
||||
|
||||
public function testMakeLoggerQuiet(): void
|
||||
{
|
||||
$config = new Config('dev');
|
||||
$console = $this->makeConsole([]);
|
||||
$logger = $console->makeLogger($config, true);
|
||||
$this->assertInstanceOf(Logger::class, $logger);
|
||||
// There should be one handler attached
|
||||
$this->assertEquals(1, count($logger->getHandlers()));
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
@unlink('certs/localhost.cert.pem');
|
||||
@unlink('certs/localhost.key.pem');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use Orbit\Request;
|
||||
|
||||
final class RequestTest extends TestCase
|
||||
{
|
||||
public function testConstruct(): void
|
||||
{
|
||||
$request = new Request('foo');
|
||||
$this->assertInstanceOf(Request::class, $request);
|
||||
$this->assertSame('foo', $request->url);
|
||||
$this->assertSame('foo', $request->path);
|
||||
}
|
||||
|
||||
public function testConstructFullUrl(): void
|
||||
{
|
||||
$url = 'https://bob:haha@null.com:7000/x/y/z.bar?q=2&b=3#ok';
|
||||
$request = new Request($url);
|
||||
$this->assertInstanceOf(Request::class, $request);
|
||||
$this->assertSame($url, $request->url);
|
||||
$this->assertSame('https', $request->scheme);
|
||||
$this->assertSame('bob', $request->user);
|
||||
$this->assertSame('haha', $request->pass);
|
||||
$this->assertSame('null.com', $request->host);
|
||||
$this->assertSame('7000', $request->port);
|
||||
$this->assertSame('/x/y/z.bar', $request->path);
|
||||
$this->assertSame('q=2&b=3', $request->query);
|
||||
$this->assertSame('ok', $request->fragment);
|
||||
}
|
||||
|
||||
public function testGetUrlAppendPath(): void
|
||||
{
|
||||
$url = 'gemini://null.com/sub/?real=1';
|
||||
$request = new Request($url);
|
||||
$updated = $request->getUrlAppendPath('addition');
|
||||
$this->assertSame('gemini://null.com/sub/addition?real=1', $updated);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use Orbit\Response;
|
||||
|
||||
final class ResponseTest extends TestCase
|
||||
{
|
||||
public function testConstruct(): void
|
||||
{
|
||||
$response = new Response();
|
||||
$this->assertInstanceOf(Response::class, $response);
|
||||
}
|
||||
|
||||
public function testGetHeader(): void
|
||||
{
|
||||
$response = new Response("20", "text/plain");
|
||||
$this->assertSame("20 text/plain\r\n", $response->getHeader());
|
||||
}
|
||||
|
||||
public function testSendWithBody(): void
|
||||
{
|
||||
$response = new Response("20", "text/plain");
|
||||
$response->body = 'xyz';
|
||||
|
||||
$fp = fopen("php://memory", "w");
|
||||
$result = $response->send($fp);
|
||||
|
||||
$this->assertSame(3, $result);
|
||||
}
|
||||
|
||||
public function testSendWithFile(): void
|
||||
{
|
||||
file_put_contents('test.txt', 'hiho');
|
||||
$response = new Response("20", "text/plain");
|
||||
$response->filepath = 'test.txt';
|
||||
|
||||
$fp = fopen("php://memory", "w");
|
||||
$result = $response->send($fp);
|
||||
|
||||
$this->assertSame(4, $result);
|
||||
@unlink('test.txt');
|
||||
}
|
||||
|
||||
public function testSendResourceBrokenPipe(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage("Failed to write to client");
|
||||
|
||||
$response = new Response("20", "text/plain");
|
||||
$response->body = "123";
|
||||
|
||||
// Cannot write to this read-only stream resource
|
||||
$fp = fopen("php://memory", "r");
|
||||
$result = $response->send($fp);
|
||||
}
|
||||
|
||||
public function testSendInvalidResource(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage("Invalid resource to write to");
|
||||
|
||||
$response = new Response("20", "text/plain");
|
||||
$response->body = "123";
|
||||
|
||||
$fp = null;
|
||||
$result = $response->send($fp);
|
||||
}
|
||||
|
||||
public function testSendWithFileNoExists(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage("Error reading file");
|
||||
|
||||
$response = new Response("20", "text/plain");
|
||||
$response->filepath = 'nofile.txt';
|
||||
|
||||
$fp = fopen("php://memory", "w");
|
||||
$result = $response->send($fp);
|
||||
|
||||
$this->assertSame(4, $result);
|
||||
}
|
||||
|
||||
public function testSendWithDirectory(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage("Cannot serve directory");
|
||||
|
||||
$dir = "test-aloof";
|
||||
mkdir($dir);
|
||||
$response = new Response("20", "text/plain");
|
||||
$response->filepath = $dir;
|
||||
|
||||
$fp = fopen("php://memory", "w");
|
||||
$result = $response->send($fp);
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
public function testSetBody(): void
|
||||
{
|
||||
$response = new Response("20", "text/plain");
|
||||
$response->setBody('olive juice');
|
||||
|
||||
$body = $response->getBody();
|
||||
$this->assertEquals('olive juice', $body);
|
||||
}
|
||||
|
||||
public function testGetBodyWithFile(): void
|
||||
{
|
||||
file_put_contents('elem.gmi', '# Hi there');
|
||||
$response = new Response("20", "text/plain");
|
||||
$response->setStaticFile('elem.gmi');
|
||||
|
||||
$body = $response->getBody();
|
||||
$this->assertEquals('# Hi there', $body);
|
||||
|
||||
unlink('elem.gmi');
|
||||
}
|
||||
|
||||
public function testSetStatusSetMeta(): void
|
||||
{
|
||||
$response = new Response();
|
||||
$response->setStatus(20);
|
||||
$response->setMeta('text/gemini');
|
||||
|
||||
$header = $response->getHeader();
|
||||
$this->assertEquals("20 text/gemini\r\n", $header);
|
||||
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
@rmdir('test-aloof');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,280 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit\Tests;
|
||||
|
||||
use Monolog\Logger;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use Orbit\Cert;
|
||||
use Orbit\Config;
|
||||
use Orbit\Server;
|
||||
use Orbit\Request;
|
||||
use Orbit\Response;
|
||||
|
||||
final class ServerTest extends TestCase
|
||||
{
|
||||
public function makeTestLogger(): Logger
|
||||
{
|
||||
$logger = new Logger('test-orbit');
|
||||
$logger->pushHandler(new \Monolog\Handler\TestHandler());
|
||||
|
||||
return $logger;
|
||||
}
|
||||
|
||||
public function getTestLogRecords($logger): array
|
||||
{
|
||||
return $logger->getHandlers()[0]->getRecords();
|
||||
}
|
||||
|
||||
public function getTestLogMessages($logger): array
|
||||
{
|
||||
$messages = [];
|
||||
foreach ($this->getTestLogRecords($logger) as $message) {
|
||||
$messages[] = $message['level_name'] . ": " . $message['message'];
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public function testConstructFailsWithoutConfig(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage("Missing required cert file");
|
||||
|
||||
$config = new Config();
|
||||
$server = new Server($config);
|
||||
}
|
||||
|
||||
public function testConstructAutoCert(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$server = new Server($config);
|
||||
$this->assertInstanceOf(Server::class, $server);
|
||||
$this->assertEquals(1, count($server->getLogger()->getHandlers()));
|
||||
}
|
||||
|
||||
public function testConstructSetLogger(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$logger = new Logger('orbit-test');
|
||||
$server = new Server($config, null, $logger);
|
||||
|
||||
$l = $server->getLogger();
|
||||
$this->assertSame($l, $logger);
|
||||
$this->assertEquals(0, count($l->getHandlers()));
|
||||
}
|
||||
|
||||
public function testConstructSetCert(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$cert = new Cert($config);
|
||||
$server = new Server($config, $cert);
|
||||
|
||||
$this->assertSame($cert, $server->cert);
|
||||
}
|
||||
|
||||
public function testConstructSetsTimeout(): void
|
||||
{
|
||||
ini_set("default_socket_timeout", "32");
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
$this->assertEquals(32, $server->timeout);
|
||||
}
|
||||
|
||||
public function testIntakeConnectionsMustBeResource(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage("Stream server must be resource object");
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
|
||||
$s = new \stdClass();
|
||||
$result = $server->intakeConnections($s);
|
||||
}
|
||||
|
||||
public function testValidateRequestMustContainHost(): void
|
||||
{
|
||||
$request = new Request('');
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertFalse($is_valid);
|
||||
$this->assertSame(Response::STATUS_BAD_REQUEST, $response->status);
|
||||
$this->assertStringContainsString("Bad request", $response->meta);
|
||||
}
|
||||
|
||||
public function testValidateRequestMustHaveCorrectScheme(): void
|
||||
{
|
||||
$request = new Request('https://foo.bar/');
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertFalse($is_valid);
|
||||
$this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status);
|
||||
$this->assertStringContainsString("unsupported scheme", $response->meta);
|
||||
}
|
||||
|
||||
public function testValidateRequestMustHaveCorrectHost(): void
|
||||
{
|
||||
$request = new Request('gemini://superfly.com/');
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertFalse($is_valid);
|
||||
$this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status);
|
||||
$this->assertStringContainsString("invalid host", $response->meta);
|
||||
}
|
||||
|
||||
public function testValidateRequestMustHaveCorrectPort(): void
|
||||
{
|
||||
$request = new Request('gemini://localhost:8080/');
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertFalse($is_valid);
|
||||
$this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status);
|
||||
$this->assertStringContainsString("invalid port", $response->meta);
|
||||
}
|
||||
|
||||
public function testValidateRequestCanIncludePort(): void
|
||||
{
|
||||
$request = new Request('gemini://localhost:1965/');
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertTrue($is_valid);
|
||||
$this->assertSame('', $response->status);
|
||||
}
|
||||
|
||||
public function testValidateRequestMustNotBeMoreThanLimit(): void
|
||||
{
|
||||
$url = 'gemini://localhost/';
|
||||
$request = new Request($url . str_repeat("x", 1025 - strlen($url)));
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertFalse($is_valid);
|
||||
$this->assertSame(Response::STATUS_BAD_REQUEST, $response->status);
|
||||
$this->assertStringContainsString("too long", $response->meta);
|
||||
}
|
||||
|
||||
public function testValidateRequestCanBeAtLimit(): void
|
||||
{
|
||||
$url = 'gemini://localhost/';
|
||||
$request = new Request($url . str_repeat("x", 1024 - strlen($url)));
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertTrue($is_valid);
|
||||
$this->assertSame('', $response->status);
|
||||
}
|
||||
|
||||
public function testValidateRequestMustNotContainNonUtf8Bytes(): void
|
||||
{
|
||||
$request = new Request('gemini://localhost/' . chr(240) . chr(159) . chr(144) . chr(152));
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertFalse($is_valid);
|
||||
$this->assertSame(Response::STATUS_BAD_REQUEST, $response->status);
|
||||
$this->assertStringContainsString("non-UTF8", $response->meta);
|
||||
}
|
||||
|
||||
public function testValidateRequestLocalhostIsValid(): void
|
||||
{
|
||||
$request = new Request('gemini://localhost/');
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertTrue($is_valid);
|
||||
$this->assertSame('', $response->status);
|
||||
}
|
||||
|
||||
public function testValidateRequestLoopbackAddrIsValid(): void
|
||||
{
|
||||
$request = new Request('gemini://127.0.0.1/');
|
||||
|
||||
$server = new Server(new Config(true));
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertTrue($is_valid);
|
||||
$this->assertSame('', $response->status);
|
||||
}
|
||||
|
||||
public function testValidateRequestMatchesConfigHostname(): void
|
||||
{
|
||||
$request = new Request('gemini://dogdays.dev/');
|
||||
|
||||
$config = new Config(true);
|
||||
$config->hostname = 'dogdays.dev';
|
||||
$server = new Server($config);
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertTrue($is_valid);
|
||||
$this->assertSame('', $response->status);
|
||||
@unlink("certs/dogdays.dev.cert.pem");
|
||||
@unlink("certs/dogdays.dev.key.pem");
|
||||
}
|
||||
|
||||
public function testValidateRequestMatchesConfigPort(): void
|
||||
{
|
||||
$request = new Request('gemini://localhost:1972/');
|
||||
|
||||
$config = new Config(true);
|
||||
$config->port = '1972';
|
||||
$server = new Server($config);
|
||||
[$is_valid, $response] = $server->validateRequest($request);
|
||||
|
||||
$this->assertTrue($is_valid);
|
||||
$this->assertSame('', $response->status);
|
||||
}
|
||||
|
||||
public function testOnWarningTimedOut(): void
|
||||
{
|
||||
$logger = $this->makeTestLogger();
|
||||
$server = new Server(new Config(true), null, $logger);
|
||||
|
||||
$result = $server->onWarning(1, 'Connection timed out');
|
||||
$this->assertTrue($result);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertNotContains('Error while accepting', $messages);
|
||||
}
|
||||
|
||||
public function testOnWarningError(): void
|
||||
{
|
||||
$logger = $this->makeTestLogger();
|
||||
$server = new Server(new Config(true), null, $logger);
|
||||
|
||||
$result = $server->onWarning(1, 'Something terrible happened');
|
||||
$this->assertTrue($result);
|
||||
$messages = $this->getTestLogMessages($logger);
|
||||
$this->assertStringContainsString('Error while accepting connection', $messages[3]);
|
||||
}
|
||||
|
||||
public function testGetListenAddress(): void
|
||||
{
|
||||
$config = new Config(true);
|
||||
$config->host = '127.0.0.1';
|
||||
$config->port = 1888;
|
||||
$server = new Server($config);
|
||||
|
||||
$address = $server->getListenAddress();
|
||||
|
||||
$this->assertSame('tls://127.0.0.1:1888', $address);
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
@unlink("certs/localhost.cert.pem");
|
||||
@unlink("certs/localhost.key.pem");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use Orbit\Util;
|
||||
|
||||
final class UtilTest extends TestCase
|
||||
{
|
||||
public function testHexView(): void
|
||||
{
|
||||
$result = Util::hexView("aa");
|
||||
|
||||
$this->assertStringContainsString("61 61", $result);
|
||||
$this->assertStringContainsString("aa", $result);
|
||||
}
|
||||
|
||||
public function testHexViewSpecialChars(): void
|
||||
{
|
||||
$result = Util::hexView("√√ç∂¥˙˚ˆø©ååœ");
|
||||
|
||||
$this->assertStringContainsString("E2 88 9A E2 88 9A C3 A7 E2 88 82 C2 A5 CB 99 CB", $result);
|
||||
$this->assertStringContainsString("9A CB 86 C3 B8 C2 A9 C3 A5 C3 A5 C5 93", $result);
|
||||
}
|
||||
|
||||
public function testHexViewNonPrintableChars(): void
|
||||
{
|
||||
$result = Util::hexView(chr(1) . chr(2) . chr(3) . chr(27));
|
||||
|
||||
$this->assertStringContainsString("01 02 03 1B", $result);
|
||||
}
|
||||
|
||||
public function testHexViewMultibyteChars(): void
|
||||
{
|
||||
$result = Util::hexView("0א0ڃ");
|
||||
|
||||
$this->assertStringContainsString("30 D7 90 30 DA 83", $result);
|
||||
$this->assertStringContainsString("0..0", $result);
|
||||
}
|
||||
|
||||
public function testHexViewEmoji(): void
|
||||
{
|
||||
$result = Util::hexView("🍪");
|
||||
|
||||
$this->assertStringContainsString("F0 9F 8D AA", $result);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue