Compare commits
18 commits
Author | SHA1 | Date | |
---|---|---|---|
3ec4797183 | |||
a33fc84627 | |||
33ab4eecdc | |||
15cd024b35 | |||
ec7a27af1d | |||
151299ea06 | |||
e7b35e0023 | |||
2991248c47 | |||
5ce094362e | |||
b6c8e590de | |||
5eb44de02d | |||
24bcbcb281 | |||
a1c3d730ca | |||
dadd3b4663 | |||
d509bcea17 | |||
457332952a | |||
fcac2b20ab | |||
881befccfa |
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
vendor
|
||||
*.log
|
||||
*.pem
|
||||
.phpunit.result.cache
|
||||
|
|
78
Makefile
Normal file
78
Makefile
Normal file
|
@ -0,0 +1,78 @@
|
|||
ccyellow = $(shell echo "\033[33m")
|
||||
ccend = $(shell tput op)
|
||||
notice = $(ccyellow) $(0) $(ccend)
|
||||
|
||||
_path := $(shell pwd)
|
||||
_user := gemini-data
|
||||
|
||||
ORBIT_HOSTNAME ?= localhost
|
||||
SERVICE_PATH = /etc/systemd/system/orbit.service
|
||||
EXEC_PATH = /usr/local/bin/orbit
|
||||
CONF_PATH = /etc/orbit
|
||||
LOG_PATH = /var/log/orbit
|
||||
|
||||
help:
|
||||
@echo "Orbit Gemini protocol server"
|
||||
@echo "To run tests, run 'make test'"
|
||||
@echo "To install as a service on your system run:"
|
||||
@echo " 'sudo make install ORBIT_HOSTNAME=localhost'"
|
||||
@echo " But replace localhost with your desired hostname"
|
||||
|
||||
test:
|
||||
cd tests; ../vendor/bin/phpunit .
|
||||
|
||||
install_bin:
|
||||
@echo
|
||||
@echo "$(ccyellow)> Installing orbit bin$(ccend)"
|
||||
rm -f $(EXEC_PATH)
|
||||
ln -s $(_path)/bin/orbit $(EXEC_PATH)
|
||||
|
||||
install_conf:
|
||||
@echo
|
||||
@echo "$(ccyellow)> Installing orbit config; Hostname: $(ORBIT_HOSTNAME)$(ccend)"
|
||||
install -d $(CONF_PATH)
|
||||
install config/localhost.ini $(CONF_PATH)/config.ini
|
||||
sed -i -e 's#Example##' $(CONF_PATH)/config.ini
|
||||
sed -i -e 's#localhost#$(ORBIT_HOSTNAME)#' $(CONF_PATH)/config.ini
|
||||
sed -i -e 's#certs#$(CONF_PATH)/certs#' $(CONF_PATH)/config.ini
|
||||
sed -i -e 's#log/#$(LOG_PATH)/#' $(CONF_PATH)/config.ini
|
||||
install -o $(_user) -d $(LOG_PATH)
|
||||
|
||||
install_cert:
|
||||
ifeq (,$(wildcard $(CONF_PATH)/certs/$(ORBIT_HOSTNAME).cert.pem))
|
||||
@echo
|
||||
@echo "$(ccyellow)> Generating certs for host $(ORBIT_HOSTNAME)$(ccend)"
|
||||
bin/makecert $(ORBIT_HOSTNAME)
|
||||
install -d $(CONF_PATH)/certs
|
||||
install certs/$(ORBIT_HOSTNAME).cert.pem $(CONF_PATH)/certs/$(ORBIT_HOSTNAME).cert.pem
|
||||
install certs/$(ORBIT_HOSTNAME).key.pem $(CONF_PATH)/certs/$(ORBIT_HOSTNAME).key.pem
|
||||
rm -rf certs
|
||||
endif
|
||||
|
||||
install_user:
|
||||
ifeq ($(shell id -u $(_user) 2>/dev/null),)
|
||||
@echo
|
||||
@echo "$(ccyellow)> Creating user $(_user)$(ccend)"
|
||||
useradd -m -d /var/gemini -s /usr/sbin/nologin -c "Orbit gemini service account" -U $(_user)
|
||||
endif
|
||||
|
||||
install_service:
|
||||
@echo
|
||||
@echo "$(ccyellow)> Creating orbit.service file at $(SERVICE_PATH)$(ccend)"
|
||||
install orbit.service $(SERVICE_PATH)
|
||||
sed -i -e 's/{USER}/$(_user)/' $(SERVICE_PATH)
|
||||
|
||||
install: install_bin install_user install_conf install_cert install_service
|
||||
@echo
|
||||
@echo "--------------------------------"
|
||||
@echo "done"
|
||||
@echo "> The service runs with the user $(_user)"
|
||||
@echo "> The actual orbit script is at $(EXEC_PATH)"
|
||||
@echo " - But that is just a symlink to $(_path)/bin/orbit"
|
||||
@echo "> I created a config file at $(CONF_PATH)/config.ini"
|
||||
@echo " - Edit settings in that file to change configuration settings"
|
||||
@echo "> I created a self-signed cert at $(CONF_PATH)/certs"
|
||||
@echo "> The server log will be written at $(LOG_PATH)/orbit.log"
|
||||
@echo "Run 'sudo systemctl start orbit' to start the service"
|
||||
@echo "Run 'sudo systemctl restart orbit' to restart the service"
|
||||
@echo "Run 'sudo systemctl enable orbit' to configure orbit to auto-start when machine starts"
|
230
README.md
230
README.md
|
@ -1,3 +1,229 @@
|
|||
# orbit
|
||||
# 🛰️ Orbit
|
||||
|
||||
Server for gemini protocol written in PHP
|
||||
Server for gemini protocol written in PHP
|
||||
|
||||
## Dependencies
|
||||
|
||||
- PHP 7.x or higher
|
||||
- composer (see getcomposer.org for download/install instructions)
|
||||
|
||||
For dev dependencies:
|
||||
- php-xml
|
||||
- php-mbstring
|
||||
|
||||
You can install PHP + required dev extensions on debian-based linux with
|
||||
|
||||
```
|
||||
sudo apt install php-cli php-xml php-mbstring
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Clone this repository to a suitable location.
|
||||
|
||||
```
|
||||
git clone https://tildegit.org/sumpygump/orbit.git
|
||||
```
|
||||
|
||||
Fetch composer dependencies.
|
||||
|
||||
```
|
||||
cd orbit
|
||||
composer update
|
||||
```
|
||||
|
||||
You can choose to do `composer update --no-dev` to not include the dev dependencies (to run unit tests).
|
||||
|
||||
## Setup
|
||||
|
||||
After installation, you can invoke the service with `bin/orbit`. You should pass in some important configuration options. See the configuration section below for more details.
|
||||
|
||||
### Quickstart
|
||||
|
||||
For a quickstart, you can do the following:
|
||||
|
||||
```
|
||||
sudo mkdir /var/gemini
|
||||
sudo sh -c 'echo "# Hello world\n\nServer is running" > /var/gemini/index.gmi'
|
||||
bin/orbit -c config/localhost.ini --dev
|
||||
```
|
||||
|
||||
This will create a new directory to be the root of your gemini server at `/var/gemini`. Then we create an example index file at the root of the server. Then we run orbit pointing to the config file at `config/localhost.ini`. Running orbit with the `--dev` flag will automatically create the server key and cert files.
|
||||
|
||||
### Install as service
|
||||
|
||||
To install as a service on your GNU/Linux machine, take a look at the `orbit.service` file in this repository.
|
||||
|
||||
An accompanied Makefile in this repository will assist you in setting up as a service (only works on GNU/Linux). Change "localhost" in the command below to whatever the hostname for your server should be.
|
||||
|
||||
```
|
||||
sudo make install ORBIT_HOSTNAME=localhost
|
||||
```
|
||||
|
||||
This will perform the following steps:
|
||||
|
||||
- Symlink the orbit bin script at `/usr/local/bin/orbit`
|
||||
- Create config file at `/etc/orbit/config.ini`
|
||||
- Create self-signed cert for you at `/etc/orbit/certs`
|
||||
- Create a new non-login user `gemini-data` for the service to use
|
||||
- Create service file at `/etc/systemd/system/orbit.service`
|
||||
|
||||
Once this completes successfully, you may edit the config file to your liking, or install your own cert files. The default location of the server root is `/var/gemini`.
|
||||
|
||||
To start the service run `sudo systemctl start orbit` or `sudo service orbit start`
|
||||
|
||||
## Usage
|
||||
|
||||
Run `bin/orbit --help` to get some helpful command line invocation options.
|
||||
|
||||
```
|
||||
$ bin/orbit --help
|
||||
Orbit 0.4
|
||||
Usage: orbit [options]
|
||||
|
||||
Options:
|
||||
-c, --config <arg> Use specified config file (.ini) for configuration
|
||||
--host <arg> Set host/ip address to listen on (default 0.0.0.0)
|
||||
-p, --port <arg> Set port to listen on (default 1965)
|
||||
--hostname <arg> Set hostname of server (default localhost)
|
||||
--tls-cert <arg> Set cert PEM file to use (default null)
|
||||
--tls-key <arg> Set private key PEM file to use (default null)
|
||||
--tls-passphrase <arg> Set passphrase for private key
|
||||
--root-dir <arg> Set the file root directory
|
||||
--log <arg> Set log filename (default orbit.log)
|
||||
--dev Allow developer server functions (default false)
|
||||
-h, --help Show help
|
||||
-v, --verbose Include more verbose output
|
||||
-q, --quiet Print less messages
|
||||
--no-color Don't use color output
|
||||
--version Show version and exit
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Most configuration options are available to be set at the invocation of orbit. The option `-c` allows you to define a path to a config file to define any configuration options not specified as command arguments.
|
||||
|
||||
```
|
||||
; Example Orbit config file
|
||||
|
||||
; Host : The IP address designation that orbit should listen on
|
||||
host = "0.0.0.0"
|
||||
|
||||
; Port : the port that orbit should listen on
|
||||
port = 1965
|
||||
|
||||
; Hostname : the expected domain that orbit is serving
|
||||
hostname = "example.com"
|
||||
|
||||
; TLS cert file : The location to the cert file
|
||||
tls_certfile = "/etc/orbit/example.com.cert.pem"
|
||||
|
||||
; TLS key file : The location to the private key for the server
|
||||
tls_keyfile = "/etc/orbit/example.com.key.pem"
|
||||
|
||||
; Key passphrase : optional passphrase for server key
|
||||
key_passphrase = ""
|
||||
|
||||
; Log file : where orbit should log information
|
||||
log_file = "/var/log/orbit/orbit.log"
|
||||
|
||||
; Log level : Only log messages that are at or above this level
|
||||
; Possible values (from low to high):
|
||||
; - debug
|
||||
; - info
|
||||
; - notice
|
||||
; - warning
|
||||
; - error
|
||||
; - critical
|
||||
; - alert
|
||||
; - emergency
|
||||
log_level = "info"
|
||||
|
||||
; Root dir : Root directory of the server files
|
||||
root_dir = "/var/gemini"
|
||||
|
||||
; Index file : Name of the index file (when a directory is accessed)
|
||||
index_file = "index.gmi"
|
||||
|
||||
; Enable directory index : Whether orbit should serve up a directory listing
|
||||
; for directories accessed that have no index file present
|
||||
enable_directory_index = true
|
||||
```
|
||||
|
||||
### Host
|
||||
|
||||
The host config option is the IP address the server should bind to. Examples include 0.0.0.0 (to listen on all ipv4 addresses), or [::] to listen on all ipv6 addresses.
|
||||
|
||||
In a config file, the option is specified with `host = "0.0.0.0"`. From the command line arguments, the option is specified with `--host 0.0.0.0`
|
||||
|
||||
### Port
|
||||
|
||||
The port config option is which port the server will listen for incoming connections. The default for gemini is 1965, but orbit can be bound to any port that is available.
|
||||
|
||||
If the port is not available, you will see the following error message "Exception: Error 0: Address already in use"
|
||||
|
||||
In a config file, the option is specified with `port = 1965`. In command line arguments, the option is specified with `--port 1965`
|
||||
|
||||
### Hostname
|
||||
|
||||
The hostname config option tells orbit which hostname the server is serving. It will deny requests that do match this domain.
|
||||
|
||||
Config file option: `hostname = localhost`. Command line arg: `--hostname localhost`
|
||||
|
||||
### TLS cert file
|
||||
|
||||
The TLS cert file config option allows you to define the path to the cert file in PEM format.
|
||||
|
||||
Config file option: `tls_certfile = "/path/to/cert.pem"`. Command line arg: `--tls-cert /path/to/cert.pem`.
|
||||
|
||||
### TLS key file
|
||||
|
||||
The TLS key file config option allows you to define the path to the private key file for the server in PEM format.
|
||||
|
||||
Config file option: `tls_keyfile = "/path/to/key.pem"`. Command line arg: `--tls-key /path/to/key.pem`.
|
||||
|
||||
### Key passphrase
|
||||
|
||||
If the private key requires a passphrase, you can specifiy it in the config option.
|
||||
|
||||
Config file option: `key_passphrase = "secret"`. Command line arg: `--tls-passphrase secret`.
|
||||
|
||||
### Log file
|
||||
|
||||
The log file config option allows you to define the location where the orbit log file should write to. It must be writable by the user used to launch orbit.
|
||||
|
||||
Config file option: `log_file = "/path/to/orbit.log"`. Command line arg: `--log path/to/orbit.log`.
|
||||
|
||||
### Log level
|
||||
|
||||
The log level config option allows you to specify the log level of the messages written to stdout or the log file. The levels are debug, info, notice, warning, error, critical, alert, emergency. Only log messages at or above the level specified will be include.
|
||||
|
||||
Config file option: `log_level = "info"`.
|
||||
|
||||
It is not possible to set granular levels with the command line arguments. The default is 'info'. If you use the `-v` or `--verbose` option, it will set the log level to 'debug'.
|
||||
|
||||
### Root directory
|
||||
|
||||
The root directory config option allows you to specify the path that is the root directory of the server.
|
||||
|
||||
Config file option: `root_dir = "/var/gemini"`. Command line arg: `--root-dir /var/gemini`.
|
||||
|
||||
### Index file
|
||||
|
||||
If a request is made for a directory, orbit will look in the directory for the index file and serve it. Define what the name of this file will be by setting the index file config option. The default is "index.gmi".
|
||||
|
||||
Config file option: `index_file = "index.gmi"`.
|
||||
|
||||
### Enable directory index
|
||||
|
||||
If a request is made for a directory and there is no index file in the target directory, orbit can optionally return a list of files/directories within that directory. To turn this on, set the enable directory index config option. The default is to for this to be enabled.
|
||||
|
||||
Config file option: `enable_directory_index = true`.
|
||||
|
||||
### `--dev` option
|
||||
|
||||
The `--dev` command line argument flag tells orbit that this is a development server environment. When this is enabled, orbit will automatically create a self-signed cert at runtime.
|
||||
|
||||
### `-q / --quiet` option
|
||||
|
||||
The `--quiet` command line argument flag tells orbit to not output anything to stdout.
|
||||
|
|
|
@ -37,6 +37,7 @@ $args = new \Qi_Console_ArgV(
|
|||
'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)',
|
||||
'tls-passphrase:' => 'Set passphrase for private key',
|
||||
'root-dir:' => 'Set the file root directory',
|
||||
'log:' => 'Set log filename (default orbit.log)',
|
||||
'dev' => 'Allow developer server functions (default false)',
|
||||
|
|
|
@ -9,9 +9,17 @@
|
|||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"sumpygump/qi-console": "^1.3",
|
||||
"monolog/monolog": "^2.1"
|
||||
"monolog/monolog": "^2.1",
|
||||
"neitanod/forceutf8": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.3"
|
||||
},
|
||||
"bin": [
|
||||
"bin/orbit"
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4" : {
|
||||
"Orbit\\" : "src/Orbit"
|
||||
|
|
2011
composer.lock
generated
2011
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -34,7 +34,7 @@ log_file = "log/orbit.log"
|
|||
log_level = "info"
|
||||
|
||||
; Root dir : Root directory of the server files
|
||||
root_dir = "."
|
||||
root_dir = "/var/gemini"
|
||||
|
||||
; Index file : Name of the index file (when a directory is accessed)
|
||||
index_file = "index.gmi"
|
||||
|
|
14
orbit.service
Normal file
14
orbit.service
Normal file
|
@ -0,0 +1,14 @@
|
|||
[Unit]
|
||||
Description=Orbit gemini server
|
||||
After=network.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
User={USER}
|
||||
ExecStart=/usr/local/bin/orbit --config /etc/orbit/config.ini -q
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -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 = [
|
||||
|
@ -116,7 +138,7 @@ class Cert
|
|||
];
|
||||
|
||||
$days_valid = 365;
|
||||
$san_domains = ["DNS:" . $this->hostname, "IP:127.0.0.1", "IP:0.0.0.0"];
|
||||
$san_domains = ["DNS:" . $this->hostname, "IP:127.0.0.1", "IP:0.0.0.0", "IP:::1"];
|
||||
$ssl_config = $this->createOpenSslConf($san_domains);
|
||||
|
||||
$csr_config = ['digest_alg' => 'sha256', 'req_extensions' => 'v3_req', 'config' => $ssl_config];
|
||||
|
@ -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,37 +26,58 @@ 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): 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'
|
||||
'tls_keyfile', 'key_passphrase', 'log_file', 'log_level',
|
||||
'root_dir', 'index_file', 'enable_directory_index',
|
||||
];
|
||||
|
||||
foreach ($ini as $key => $value) {
|
||||
foreach ($params as $key => $value) {
|
||||
if (!in_array($key, $valid_keys)) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
@ -45,11 +70,11 @@ class Console extends \Qi_Console_Client
|
|||
$config->readFromIniFile($this->_args->config);
|
||||
}
|
||||
|
||||
if ($this->_args->host) {
|
||||
if ($this->_args->host || $this->_args->host == "0") {
|
||||
$config->host = $this->_args->host;
|
||||
}
|
||||
|
||||
if ($this->_args->port) {
|
||||
if ($this->_args->port || $this->_args->port == "0") {
|
||||
$config->port = $this->_args->port;
|
||||
}
|
||||
|
||||
|
@ -77,10 +102,21 @@ class Console extends \Qi_Console_Client
|
|||
$config->tls_keyfile = $this->_args->get("tls-key");
|
||||
}
|
||||
|
||||
if ($this->_args->get("tls-passphrase")) {
|
||||
$config->key_passphrase = $this->_args->get("tls-passphrase");
|
||||
}
|
||||
|
||||
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 +138,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 +159,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();
|
||||
|
||||
|
|
43
src/Orbit/Module.php
Normal file
43
src/Orbit/Module.php
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?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 implements ModuleInterface
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()];
|
||||
}
|
||||
}
|
197
src/Orbit/Module/Statics.php
Normal file
197
src/Orbit/Module/Statics.php
Normal file
|
@ -0,0 +1,197 @@
|
|||
<?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
|
||||
{
|
||||
const WORLD_READABLE = 0x0004;
|
||||
|
||||
/**
|
||||
* Handle a request and generate a proper response
|
||||
*
|
||||
* @param Request $request The request object
|
||||
*/
|
||||
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
|
||||
// getAbsolutePath will translate any '..' in the path
|
||||
$realpath = self::getAbsolutePath($resource_path);
|
||||
if ($realpath && strpos($realpath, $real_root_dir) !== 0) {
|
||||
$response->setStatus(Response::STATUS_PERMANENT_FAILURE);
|
||||
$response->setMeta("Invalid location");
|
||||
return [false, $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 [false, $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_NOT_FOUND);
|
||||
$response->setMeta('Path not available');
|
||||
return [false, $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::isWorldReadable($resource_path)) {
|
||||
$response->setStatus(Response::STATUS_SUCCESS);
|
||||
|
||||
$pathinfo = pathinfo($resource_path);
|
||||
|
||||
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 {
|
||||
$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";
|
||||
|
||||
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);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an absolute path for a filename
|
||||
*
|
||||
* Translates .. and . to the real locations. The reason I am not using
|
||||
* realpath() to do it is it resolves symlinks
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
public static function getAbsolutePath($path): string
|
||||
{
|
||||
$path = str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $path);
|
||||
$parts = array_filter(explode(DIRECTORY_SEPARATOR, $path), 'strlen');
|
||||
$absolutes = [];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if ('.' == $part) {
|
||||
continue;
|
||||
}
|
||||
if ('..' == $part) {
|
||||
array_pop($absolutes);
|
||||
} else {
|
||||
$absolutes[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
return "/" . implode(DIRECTORY_SEPARATOR, $absolutes);
|
||||
}
|
||||
}
|
26
src/Orbit/ModuleInterface.php
Normal file
26
src/Orbit/ModuleInterface.php
Normal 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;
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
<?php
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
/**
|
||||
* Request
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Request
|
||||
{
|
||||
public $url = '';
|
||||
|
@ -14,17 +19,33 @@ 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 = $request_input;
|
||||
$data = parse_url($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
|
||||
if (!$this->scheme) {
|
||||
$this->scheme = Server::SCHEME;
|
||||
}
|
||||
}
|
||||
|
||||
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,34 +36,70 @@ 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 response body to client
|
||||
*
|
||||
* @param resource $client
|
||||
* @return int|false Number of bytes written
|
||||
*/
|
||||
public function send($client): int
|
||||
{
|
||||
fwrite($client, $this->getHeader());
|
||||
if (!is_resource($client)) {
|
||||
throw new \Exception("Invalid resource to write to");
|
||||
}
|
||||
$result = fwrite($client, $this->getHeader());
|
||||
|
||||
if (!$result) {
|
||||
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
|
||||
// So, result will be 0 if the client cancels (broken socket)
|
||||
$result = fwrite($client, fread($fp, 8192));
|
||||
}
|
||||
fclose($fp);
|
||||
return $size;
|
||||
} else {
|
||||
$body = $this->getBody();
|
||||
|
@ -64,35 +108,83 @@ 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(int $status): void
|
||||
{
|
||||
$this->status = $status;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMeta($meta)
|
||||
/**
|
||||
* Get status
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getStatus(): int
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set response meta value
|
||||
*
|
||||
* @param string $meta
|
||||
* @return void
|
||||
*/
|
||||
public function setMeta($meta): void
|
||||
{
|
||||
$this->meta = $meta;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meta
|
||||
*
|
||||
* @return string Meta value
|
||||
*/
|
||||
public function getMeta(): string
|
||||
{
|
||||
return $this->meta;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
<?php
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Orbit;
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use ForceUTF8\Encoding;
|
||||
|
||||
use Orbit\Module\Statics;
|
||||
|
||||
/**
|
||||
* Server
|
||||
*
|
||||
* @package Orbit
|
||||
*/
|
||||
class Server
|
||||
{
|
||||
public static $version = "0.3";
|
||||
const SCHEME = "gemini";
|
||||
|
||||
public static $version = "0.5";
|
||||
|
||||
public $config;
|
||||
public $cert;
|
||||
|
@ -15,64 +25,79 @@ class Server
|
|||
|
||||
private $ssl_context;
|
||||
private $logger;
|
||||
private $connections = []; // Incoming client connections
|
||||
private $peers = []; // Client connections to read from
|
||||
private $metas = []; // Meta data for each client connection
|
||||
|
||||
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
|
||||
*
|
||||
* @return 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");
|
||||
}
|
||||
|
||||
$path = realpath($root_dir);
|
||||
|
||||
$this->logger->debug("Root directory '$path'");
|
||||
$this->logger->debug(sprintf("Root directory '%s'", realpath($root_dir)));
|
||||
|
||||
$server = stream_socket_server(
|
||||
$this->getListenAddress(),
|
||||
$errno, $errstr,
|
||||
$errno,
|
||||
$errstr,
|
||||
STREAM_SERVER_BIND|STREAM_SERVER_LISTEN,
|
||||
$this->ssl_context
|
||||
);
|
||||
|
@ -80,152 +105,261 @@ class Server
|
|||
if (!$server) {
|
||||
throw new \Exception("Error " . $errno . ": " . $errstr);
|
||||
}
|
||||
stream_set_blocking($server, true);
|
||||
|
||||
$protocol = "gemini";
|
||||
$name = stream_socket_get_name($server, false);
|
||||
$this->logger->info("Listening on $protocol://$name ...");
|
||||
$this->logger->info(sprintf("Listening on %s://%s...", self::SCHEME, $name));
|
||||
|
||||
$this->connections = [];
|
||||
$this->peers = [];
|
||||
$this->metas = [];
|
||||
$_write = null;
|
||||
$_exception = null;
|
||||
|
||||
while (true) {
|
||||
# This is to swallow up the `timeout` warning
|
||||
set_error_handler([$this, 'onWarning']);
|
||||
$client = stream_socket_accept($server, $this->timeout, $client_name);
|
||||
restore_error_handler();
|
||||
// Handle incoming new connections
|
||||
$this->intakeConnections($server);
|
||||
|
||||
if ($client) {
|
||||
$time = ['start' => microtime(true)];
|
||||
if (count($this->connections) == 0) {
|
||||
continue;
|
||||
}
|
||||
$this->peers = array_values($this->connections);
|
||||
|
||||
$this->logger->debug("$client_name Accepted");
|
||||
$request_buffer = stream_get_line($client, 1024, "\r\n");
|
||||
$this->logger->info("REQ: $request_buffer", ["client" => $client_name]);
|
||||
$request = new Request($request_buffer);
|
||||
|
||||
// Respond to client
|
||||
$response = $this->handleResponse($request, $path);
|
||||
$size = $response->send($client);
|
||||
$time['end'] = microtime(true);
|
||||
$this->logger->debug(
|
||||
"RSP: " . trim($response->getHeader()),
|
||||
['size' => $size, 'time' => $time['end'] - $time['start']]
|
||||
);
|
||||
|
||||
fclose($client);
|
||||
$this->logger->debug("$client_name Closed");
|
||||
// Handle reading from and responding to connections
|
||||
if (stream_select($this->peers, $_write, $_exception, 5)) {
|
||||
foreach ($this->peers as $client) {
|
||||
$this->handlePeerConnection($client);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function handleResponse($request, $dir)
|
||||
/**
|
||||
* Intake connections
|
||||
*
|
||||
* @param resource $server
|
||||
* @return bool
|
||||
*/
|
||||
public function intakeConnections($server): bool
|
||||
{
|
||||
$resource_path = rtrim($dir, "/") . $request->path;
|
||||
|
||||
$response = new 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;
|
||||
}
|
||||
}
|
||||
if (!is_resource($server)) {
|
||||
throw new \Exception("Stream server must be resource object");
|
||||
}
|
||||
|
||||
// File exists and is world readable
|
||||
if (file_exists($resource_path) && (fileperms($resource_path) & 0x0004)) {
|
||||
$response->setStatus(Response::STATUS_SUCCESS);
|
||||
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();
|
||||
|
||||
$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!');
|
||||
if (!$client) {
|
||||
# If the listener times out, it will run this and then start
|
||||
# listening again
|
||||
$this->pruneExpiredConnections();
|
||||
return false;
|
||||
}
|
||||
|
||||
$time = ['start' => microtime(true)];
|
||||
$meta = stream_get_meta_data($client);
|
||||
|
||||
$this->logger->debug("$peer Accepted", $meta);
|
||||
$this->connections[$peer] = $client;
|
||||
$this->metas[$peer] = ["input" => "", "time" => $time];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single peer connection (client request)
|
||||
*
|
||||
* @param resource $client
|
||||
* @return void
|
||||
*/
|
||||
public function handlePeerConnection($client): void
|
||||
{
|
||||
$peer = stream_socket_get_name($client, true);
|
||||
|
||||
if (!$peer) {
|
||||
// If it went away use the array key as the name to close it
|
||||
$peer = array_search($client, $this->connections);
|
||||
}
|
||||
|
||||
$this->metas[$peer]['input'] .= fread($client, 1026);
|
||||
if (mb_strlen($this->metas[$peer]['input']) >= 1024
|
||||
|| strpos($this->metas[$peer]['input'], "\r\n") !== false
|
||||
) {
|
||||
$request_buffer = $this->metas[$peer]['input'];
|
||||
//print(Util::hexView($request_buffer));
|
||||
//print("Length: " . mb_strlen($request_buffer) . "\n");
|
||||
$this->logger->info("REQ: $request_buffer", ['client' => $peer]);
|
||||
|
||||
$request = new Request($request_buffer);
|
||||
|
||||
// Respond to client
|
||||
$response = $this->handle($request);
|
||||
$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]);
|
||||
unset($this->metas[$peer]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle creating a response and sending it to a client
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
[$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;
|
||||
}
|
||||
|
||||
$statics_module = new Statics($this->config, $this->logger);
|
||||
[$success, $response] = $statics_module->handle($request);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function log($level, $message, $context = [])
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$this->getLogger()->log($level, $message, $context);
|
||||
$now = microtime(true);
|
||||
foreach ($this->metas as $peer => $meta) {
|
||||
$delta = $now - $meta['time']['start'];
|
||||
if ($delta > $this->timeout) {
|
||||
$this->logger->debug(sprintf("Pruning expired connection %s (%.02f seconds over)", $peer, $delta));
|
||||
if (isset($this->connections[$peer])) {
|
||||
unset($this->connections[$peer]);
|
||||
}
|
||||
unset($this->metas[$peer]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function onWarning($id, $message)
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if (strpos($message, "timed out") !== false) {
|
||||
$response = new Response();
|
||||
|
||||
// Valid URL must contain a host
|
||||
if (!$request->host) {
|
||||
$response->setStatus(Response::STATUS_BAD_REQUEST);
|
||||
$response->setMeta("Bad request - url is empty");
|
||||
return [false, $response];
|
||||
}
|
||||
|
||||
// Valid URL must be the target scheme
|
||||
if ($request->scheme != self::SCHEME) {
|
||||
$response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED);
|
||||
$response->setMeta("Proxy error - unsupported scheme");
|
||||
return [false, $response];
|
||||
}
|
||||
|
||||
// Valid URL must use targeted 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];
|
||||
}
|
||||
|
||||
// Valid URL must use correct port
|
||||
if ($request->port != "" && $request->port != $this->config->port) {
|
||||
$response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED);
|
||||
$response->setMeta("Proxy error - invalid port");
|
||||
return [false, $response];
|
||||
}
|
||||
|
||||
// Valid URL must not be more than 1024 chars
|
||||
if (mb_strlen($request->url) > 1024) {
|
||||
$response->setStatus(Response::STATUS_BAD_REQUEST);
|
||||
$response->setMeta("Bad request - too long");
|
||||
return [false, $response];
|
||||
}
|
||||
|
||||
// Valid URL must not contain non-UTF-8 bytes
|
||||
$conv = Encoding::fixUTF8($request->url);
|
||||
if ($conv != $request->url) {
|
||||
$response->setStatus(Response::STATUS_BAD_REQUEST);
|
||||
$response->setMeta("Bad request - non-UTF8");
|
||||
return [false, $response];
|
||||
}
|
||||
|
||||
return [true, $response];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if (strpos($errstr, "timed out") !== false) {
|
||||
// Do nothing
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Something else happened.
|
||||
$error = sprintf("Error %s: %s", $id, $message);
|
||||
$this->logger->error($error);
|
||||
throw new \Exception($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();
|
||||
|
|
41
src/Orbit/Util.php
Normal file
41
src/Orbit/Util.php
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
22
tests/phpunit.xml
Normal file
22
tests/phpunit.xml
Normal file
|
@ -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>
|
188
tests/src/Orbit/CertTest.php
Normal file
188
tests/src/Orbit/CertTest.php
Normal file
|
@ -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");
|
||||
}
|
||||
}
|
99
tests/src/Orbit/ConfigTest.php
Normal file
99
tests/src/Orbit/ConfigTest.php
Normal file
|
@ -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
|
||||
key_passphrase=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->key_passphrase);
|
||||
$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');
|
||||
}
|
||||
}
|
164
tests/src/Orbit/ConsoleTest.php
Normal file
164
tests/src/Orbit/ConsoleTest.php
Normal file
|
@ -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 ', $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 ', $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');
|
||||
}
|
||||
}
|
191
tests/src/Orbit/Module/StaticsTest.php
Normal file
191
tests/src/Orbit/Module/StaticsTest.php
Normal 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
tests/src/Orbit/ModuleTest.php
Normal file
29
tests/src/Orbit/ModuleTest.php
Normal 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);
|
||||
}
|
||||
}
|
42
tests/src/Orbit/RequestTest.php
Normal file
42
tests/src/Orbit/RequestTest.php
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
137
tests/src/Orbit/ResponseTest.php
Normal file
137
tests/src/Orbit/ResponseTest.php
Normal file
|
@ -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');
|
||||
}
|
||||
}
|
280
tests/src/Orbit/ServerTest.php
Normal file
280
tests/src/Orbit/ServerTest.php
Normal file
|
@ -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");
|
||||
}
|
||||
}
|
48
tests/src/Orbit/UtilTest.php
Normal file
48
tests/src/Orbit/UtilTest.php
Normal file
|
@ -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 a new issue