From 6df3d321d9b67c4541f50158b087d37c4b22e886 Mon Sep 17 00:00:00 2001 From: Daniel Weipert Date: Sun, 12 Nov 2023 11:16:56 +0100 Subject: initial commit --- .gitignore | 3 + Justfile | 5 ++ Readme.md | 3 + composer.json | 18 +++++ composer.lock | 18 +++++ docker-compose.yml | 9 +++ docker/server/Dockerfile | 13 +++ docker/server/server.php | 19 +++++ 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 ++++ test/client.php | 3 + test/gemtext.php | 27 +++++++ test/index.gmi | 17 ++++ test/server.php | 18 +++++ test/sub/index.gmi | 3 + test/test.gmi | 4 + 23 files changed, 659 insertions(+) create mode 100644 .gitignore create mode 100644 Justfile create mode 100644 Readme.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docker-compose.yml create mode 100644 docker/server/Dockerfile create mode 100644 docker/server/server.php 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 create mode 100644 test/client.php create mode 100644 test/gemtext.php create mode 100644 test/index.gmi create mode 100644 test/server.php create mode 100644 test/sub/index.gmi create mode 100644 test/test.gmi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe5a8ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/test/cert.pem +/test/key.rsa diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..cbcbd38 --- /dev/null +++ b/Justfile @@ -0,0 +1,5 @@ +test: + php test/server.php --document_root=$(pwd)/test + +cert-server: + openssl req -x509 -newkey rsa:4096 -keyout test/key.rsa -out test/cert.pem -days 3650 -nodes -subj "/CN=localhost" diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..d331a43 --- /dev/null +++ b/Readme.md @@ -0,0 +1,3 @@ +- https://tildegit.org/sumpygump/orbit/src/branch/master/src/Orbit +- https://github.com/michaelcaplan/jsonresume-gemini +- https://github.com/jgkaplan/gemini-server diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..57b3f64 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "dweipert/gemini-foundation", + "authors": [ + { + "name": "Daniel Weipert", + "email": "code@drogueronin.de" + } + ], + "require": {}, + "autoload": { + "psr-4": { + "GeminiFoundation\\": "src/" + }, + "files": [ + "src/functions.php" + ] + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..428e1a7 --- /dev/null +++ b/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "415b91e4d5d3afa6a5f91e80930829aa", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d9e10a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + server: + build: + context: . + dockerfile: docker/server/Dockerfile + network_mode: host + volumes: + - "./test:/usr/src/app/test" + command: ["php", "./test/server.php"] diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile new file mode 100644 index 0000000..7f37ef4 --- /dev/null +++ b/docker/server/Dockerfile @@ -0,0 +1,13 @@ +FROM php:alpine + +COPY --from=composer /usr/bin/composer /usr/bin/composer + +WORKDIR /usr/src/app + +COPY composer.json . +COPY src src +RUN composer install + +COPY docker/server/server.php . + +CMD ["php", "./server.php"] diff --git a/docker/server/server.php b/docker/server/server.php new file mode 100644 index 0000000..dee0cd8 --- /dev/null +++ b/docker/server/server.php @@ -0,0 +1,19 @@ + __DIR__ . '/certificate/cert.pem', + 'key' => __DIR__ . '/certificate/key.rsa', + 'passphrase' => '', + ], + '0.0.0.0' +); + +$server->onRequest(new DocumentServer(__DIR__ . '/content')); + +$server->listen(); 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 @@ + quote + + => /link + => /link link + GEMTEXT); + +var_dump($parser->parse()); diff --git a/test/index.gmi b/test/index.gmi new file mode 100644 index 0000000..edc8190 --- /dev/null +++ b/test/index.gmi @@ -0,0 +1,17 @@ +# Gemini Foundation PHP library + +## How to setup a Gemini Server with the Gemini Foundation PHP library + +Step one +* do it + +> just do it yoooo + +```php +$server = new Server(); +$server->onRequest(new DocumentServer()); +$server->listen(); +``` + +=> https://dweipert.de +=> test.gmi diff --git a/test/server.php b/test/server.php new file mode 100644 index 0000000..c90942a --- /dev/null +++ b/test/server.php @@ -0,0 +1,18 @@ + __DIR__ . '/cert.pem', + 'key' => __DIR__ . '/key.rsa', + 'passphrase' => '', + ], +); + +$server->onRequest(new DocumentServer(__DIR__)); + +$server->listen(); diff --git a/test/sub/index.gmi b/test/sub/index.gmi new file mode 100644 index 0000000..193cc4d --- /dev/null +++ b/test/sub/index.gmi @@ -0,0 +1,3 @@ +subfolder + +=> / diff --git a/test/test.gmi b/test/test.gmi new file mode 100644 index 0000000..fb6db92 --- /dev/null +++ b/test/test.gmi @@ -0,0 +1,4 @@ +test + +=> / +=> /sub -- cgit v1.2.3