diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Client.php | 40 | ||||
-rw-r--r-- | src/Gemtext.php | 99 | ||||
-rw-r--r-- | src/Request.php | 48 | ||||
-rw-r--r-- | src/RequestHandlerInterface.php | 8 | ||||
-rw-r--r-- | src/Response.php | 112 | ||||
-rw-r--r-- | src/Server.php | 111 | ||||
-rw-r--r-- | src/Server/RequestHandlers/DocumentServer.php | 39 | ||||
-rw-r--r-- | src/Status.php | 28 | ||||
-rw-r--r-- | src/functions.php | 14 |
9 files changed, 499 insertions, 0 deletions
diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..255e29f --- /dev/null +++ b/src/Client.php @@ -0,0 +1,40 @@ +<?php + +namespace GeminiFoundation; + +use GeminiFoundation\Response; + +class Client +{ + protected string $baseUrl; + + public function __construct($baseUrl) + { + $this->baseUrl = $baseUrl; + } + + public function request($url): Response + { + $context = stream_context_create(options: [ + 'ssl' => [ + 'verify_peer' => false, + ], + ]); + + $connection = stream_socket_client( + address: "tls://{$this->baseUrl}:1965", + context: $context + ); + + fwrite($connection, "gemini://{$this->baseUrl}{$url}"); + $responseString = ''; + while (!feof($connection)) { + $responseString .= fgets($connection, 1024); + } + fclose($connection); + + $response = Response::fromString($responseString); + + return $response; + } +} diff --git a/src/Gemtext.php b/src/Gemtext.php new file mode 100644 index 0000000..d382c45 --- /dev/null +++ b/src/Gemtext.php @@ -0,0 +1,99 @@ +<?php + +namespace GeminiFoundation; + +class Gemtext +{ + protected string $input; + protected array $output; + + protected string $preformattedMode = 'off'; + + public function __construct(string $input) + { + $this->input = $input; + } + + public function parse(): array + { + $input = fopen('php://temp', 'r+'); + fputs($input, $this->input); + rewind($input); + + $output = []; + while ($line = fgets($input)) { + $output[] = $this->parseLine($line); + } + + fclose($input); + + return $this->output = $output; + } + + private function parseLine(string $input) + { + $line = [ + 'raw' => $input, + ]; + + $input = ltrim($input); + + if (str_starts_with($input, '```')) { + $this->preformattedMode = $this->preformattedMode === 'on' ? 'off' : 'on'; + + $line['type'] = $this->preformattedMode === 'on' ? 'preformatted_on' : 'preformatted_off'; + $line['text'] = $line['alt'] = trim(substr($input, 3)); + } + else if ($this->preformattedMode === 'on') { + $line['type'] = 'preformatted'; + $line['text'] = $line['raw']; + } + + else if (str_starts_with($input, '#')) { + $firstWhitespacePosition = $this->findWhitespace($input); + + $line['type'] = 'heading'; + $line['size'] = substr_count( + haystack: $input, + needle: '#', + length: min($firstWhitespacePosition, 3) + ); + $line['text'] = trim(substr( + string: $input, + offset: min($firstWhitespacePosition, 3) + )); + } + else if (str_starts_with($input, '*')) { + $line['type'] = 'listitem'; + $line['text'] = trim(substr($input, 1)); + } + else if (str_starts_with($input, '>')) { + $line['type'] = 'quote'; + $line['text'] = trim(substr($input, 1)); + } + else if (str_starts_with($input, '=>')) { + $whitespacePosition = $this->findWhitespace($input, 3); + + $link = substr(string: $input, offset: 2, length: $whitespacePosition - 2); + $text = substr(string: $input, offset: $whitespacePosition); + + $line['type'] = 'link'; + $line['link'] = trim($link); + $line['text'] = trim($text); + } + else { + $line['type'] = 'text'; + $line['text'] = rtrim($input); + } + + return $line; + } + + private function findWhitespace(string $input, int $offset = 0): int + { + $space = strpos($input, ' ', $offset); + $tab = strpos($input, "\t", $offset); + + return $space === false ? ($tab === false ? PHP_INT_MAX : $tab) : $space; + } +} diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..586134d --- /dev/null +++ b/src/Request.php @@ -0,0 +1,48 @@ +<?php + +namespace GeminiFoundation; + +class Request +{ + protected string $scheme; + protected string $host; + protected string $path; + protected array $query; + + public function __construct(string $url) + { + $requestUrl = parse_url($url); + + $this->scheme = $requestUrl['scheme']; + $this->host = $requestUrl['host']; + $this->path = $requestUrl['path'] ?? '/'; + + $this->query = []; + if (isset($requestUrl['query'])) { + foreach (explode('&', $requestUrl['query']) as $queryString) { + $query = explode('=', $queryString); + $this->query[$query[0]] = $query[1] ?? null; + } + } + } + + /** + * @param resource $resource + */ + public static function fromResource($resource): static + { + return Request::fromString(fread($resource, 1024)); + } + + public static function fromString(string $string): static + { + $requestUrl = explode("\r\n", $string)[0] ?? ''; + + return new Request($requestUrl); + } + + public function getPath(): string + { + return $this->path; + } +} diff --git a/src/RequestHandlerInterface.php b/src/RequestHandlerInterface.php new file mode 100644 index 0000000..2d6ae90 --- /dev/null +++ b/src/RequestHandlerInterface.php @@ -0,0 +1,8 @@ +<?php + +namespace GeminiFoundation; + +interface RequestHandlerInterface +{ + public function __invoke(Response $response, Request $request): Response; +} diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..7eab1f5 --- /dev/null +++ b/src/Response.php @@ -0,0 +1,112 @@ +<?php + +namespace GeminiFoundation; + +class Response +{ + protected Status $statusCode; + protected string $meta; + protected string $body; + + /** + * @param Status $statusCode + * @param string $meta + * @param string $body + */ + public function __construct( + $statusCode = Status::SUCCESS, + $meta = '', + $body = '' + ) + { + $this->statusCode = $statusCode; + $this->meta = $meta; + $this->body = $body; + } + + public static function fromString(string $string): static + { + $parts = explode("\r\n", $string); + $header = $parts[0]; + $headerParts = explode(' ', $header); + $statusCode = Status::from(intval($headerParts[0])); + $meta = implode(' ', array_slice($headerParts, 1)); + $body = implode("\r\n", array_slice($parts, 1)); + + return new self($statusCode, $meta, $body); + } + + public function setStatusCode(Status|int $statusCode): static + { + $this->statusCode = $statusCode; + + return $this; + } + + public function getStatusCode(): Status|int + { + return $this->statusCode; + } + + public function setMeta(string $meta): static + { + $this->meta = $meta; + + return $this; + } + + public function getMeta(): string + { + return $this->meta; + } + + public function setBody(string $body): static + { + $this->body = $body; + + return $this; + } + + public function getBody(): string + { + return $this->body; + } + + public function append(string $string): static + { + $this->body .= $string; + + return $this; + } + + public function getHeader(): string + { + return "{$this->statusCode->value} {$this->meta}\r\n"; + } + + public function prepareResponse(): static + { + if ($this->statusCode == Status::SUCCESS) { + if (empty($this->meta)) { + $this->meta = 'text/gemini; charset=utf-8'; + } + } + + return $this; + } + + /** + * @param resource $connection + */ + public function send($connection): int + { + return fwrite($connection, $this); + } + + public function __toString(): string + { + $this->prepareResponse(); + + return "{$this->getHeader()}{$this->body}"; + } +} diff --git a/src/Server.php b/src/Server.php new file mode 100644 index 0000000..46c7dca --- /dev/null +++ b/src/Server.php @@ -0,0 +1,111 @@ +<?php + +namespace GeminiFoundation; + +class Server +{ + protected string $hostname; + + protected array $certificate = [ + 'file' => null, + 'key' => null, + 'passphrase' => null, + ]; + + protected array $requestHandlers = []; + + /** + * @param array $certificate + * @param string $hostname + */ + public function __construct( + array $certificate, + string $hostname = 'localhost' + ) + { + $this->certificate = $certificate; + $this->hostname = $hostname; + } + + public function setCertificate(string $certificateFile, string $keyFile, string $passphrase = ''): static + { + $this->certificate = [ + 'file' => $certificateFile, + 'key' => $keyFile, + 'passphrase' => $passphrase, + ]; + + return $this; + } + + public function onRequest(RequestHandlerInterface|callable $callable): static + { + $this->requestHandlers[] = $callable; + + return $this; + } + + public function listen(int $port = 1965): void + { + $context = stream_context_create(options: [ + 'ssl' => [ + 'local_cert' => $this->certificate['file'], + 'local_pk' => $this->certificate['key'], + 'passphrase' => $this->certificate['passphrase'], + + 'allow_self_signed' => true, + 'verify_peer' => false, + ], + ]); + + $socket = stream_socket_server( + address: "tls://{$this->hostname}:{$port}", + context: $context + ); + + $connections = []; + + while (true) { + $connection = stream_socket_accept( + socket: $socket, + timeout: empty($connections) ? -1 : 0, + peer_name: $peer + ); + + if ($connection) { + $connections[$peer] = $connection; + } + + if (count($connections) == 0) { + continue; + } + + $streams = stream_select( + read: $connections, + write: $write, + except: $except, + seconds: 5 + ); + + if ($streams) { + foreach ($connections as $peer => $connection) { + if (feof($connection)) { + fclose($connection); + unset($connections[$peer]); + continue; + } + + $request = Request::fromResource($connection); + $response = new Response(); + foreach ($this->requestHandlers as $requestHandler) { + $response = $requestHandler($response, $request); + } + $response->send($connection); + + fclose($connection); + unset($connections[$peer]); + } + } + } + } +} diff --git a/src/Server/RequestHandlers/DocumentServer.php b/src/Server/RequestHandlers/DocumentServer.php new file mode 100644 index 0000000..9689d5b --- /dev/null +++ b/src/Server/RequestHandlers/DocumentServer.php @@ -0,0 +1,39 @@ +<?php + +namespace GeminiFoundation\Server\RequestHandlers; + +use GeminiFoundation\Request; +use GeminiFoundation\RequestHandlerInterface; +use GeminiFoundation\Response; +use GeminiFoundation\Status; +use function GeminiFoundation\mime_content_type; + +class DocumentServer implements RequestHandlerInterface +{ + protected string $documentRoot; + + public function __construct(string $documentRoot = '') + { + $this->documentRoot = $documentRoot ?: getcwd(); + } + + public function __invoke(Response $response, Request $request): Response + { + $documentPath = $this->documentRoot . $request->getPath(); + if (! is_file($documentPath)) { + $documentPath = $this->documentRoot . $request->getPath() . '/index.gmi'; + } + + if (is_file($documentPath)) { + $content = file_get_contents($documentPath); + $response->setBody($content); + + $response->setStatusCode(Status::SUCCESS); + $response->setMeta(mime_content_type($documentPath) . '; charset=utf-8'); + } else { + $response->setStatusCode(Status::NOT_FOUND); + } + + return $response; + } +} diff --git a/src/Status.php b/src/Status.php new file mode 100644 index 0000000..23fef68 --- /dev/null +++ b/src/Status.php @@ -0,0 +1,28 @@ +<?php + +namespace GeminiFoundation; + +/** + * @see gemini://geminiprotocol.net/docs/specification.gmi + */ +enum Status: int +{ + case INPUT = 10; + case SENSITIVE_INPUT = 11; + case SUCCESS = 20; + case REDIRECT_TEMPORARY = 30; + case REDIRECT_PERMANENT = 31; + case TEMPORARY_FAILURE = 40; + case SERVER_UNAVAILABLE = 41; + case CGI_ERROR = 42; + case PROXY_ERROR = 43; + case SLOW_DOWN = 44; + case PERMANENT_FAILURE = 50; + case NOT_FOUND = 51; + case GONE = 52; + case PROXY_REQUEST_REFUSED = 53; + case BAD_REQUEST = 59; + case CLIENT_CERTIFICATE_REQUIRED = 60; + case CERTIFICATE_NOT_AUTHORISED = 61; + case CERTIFICATE_NOT_VALID = 62; +} diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..f8be759 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,14 @@ +<?php + +namespace GeminiFoundation; + +function mime_content_type(string $filePath): string +{ + $pathinfo = pathinfo($filePath); + + if ($pathinfo['extension'] === 'gmi') { + return 'text/gemini'; + } + + return \mime_content_type($filePath) ?: ''; +} |