From 6df3d321d9b67c4541f50158b087d37c4b22e886 Mon Sep 17 00:00:00 2001 From: Daniel Weipert Date: Sun, 12 Nov 2023 11:16:56 +0100 Subject: initial commit --- src/Client.php | 40 +++++++++ src/Gemtext.php | 99 +++++++++++++++++++++++ src/Request.php | 48 +++++++++++ src/RequestHandlerInterface.php | 8 ++ src/Response.php | 112 ++++++++++++++++++++++++++ src/Server.php | 111 +++++++++++++++++++++++++ src/Server/RequestHandlers/DocumentServer.php | 39 +++++++++ src/Status.php | 28 +++++++ src/functions.php | 14 ++++ 9 files changed, 499 insertions(+) create mode 100644 src/Client.php create mode 100644 src/Gemtext.php create mode 100644 src/Request.php create mode 100644 src/RequestHandlerInterface.php create mode 100644 src/Response.php create mode 100644 src/Server.php create mode 100644 src/Server/RequestHandlers/DocumentServer.php create mode 100644 src/Status.php create mode 100644 src/functions.php (limited to 'src') 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 @@ +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 @@ +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 @@ +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 @@ +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 @@ + 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 @@ +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 @@ +