summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--Justfile5
-rw-r--r--Readme.md3
-rw-r--r--composer.json18
-rw-r--r--composer.lock18
-rw-r--r--docker-compose.yml9
-rw-r--r--docker/server/Dockerfile13
-rw-r--r--docker/server/server.php19
-rw-r--r--src/Client.php40
-rw-r--r--src/Gemtext.php99
-rw-r--r--src/Request.php48
-rw-r--r--src/RequestHandlerInterface.php8
-rw-r--r--src/Response.php112
-rw-r--r--src/Server.php111
-rw-r--r--src/Server/RequestHandlers/DocumentServer.php39
-rw-r--r--src/Status.php28
-rw-r--r--src/functions.php14
-rw-r--r--test/client.php3
-rw-r--r--test/gemtext.php27
-rw-r--r--test/index.gmi17
-rw-r--r--test/server.php18
-rw-r--r--test/sub/index.gmi3
-rw-r--r--test/test.gmi4
23 files changed, 659 insertions, 0 deletions
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 @@
+<?php
+
+use GeminiFoundation\Server;
+use GeminiFoundation\Server\RequestHandlers\DocumentServer;
+
+require __DIR__ . '/vendor/autoload.php';
+
+$server = new Server(
+ [
+ 'file' => __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 @@
+<?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) ?: '';
+}
diff --git a/test/client.php b/test/client.php
new file mode 100644
index 0000000..50cce95
--- /dev/null
+++ b/test/client.php
@@ -0,0 +1,3 @@
+<?php
+
+
diff --git a/test/gemtext.php b/test/gemtext.php
new file mode 100644
index 0000000..2cf7ed7
--- /dev/null
+++ b/test/gemtext.php
@@ -0,0 +1,27 @@
+<?php
+
+use GeminiFoundation\Gemtext;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$parser = new Gemtext(<<<GEMTEXT
+ # heading
+
+ text
+
+ * listitem 1
+ * listitem 2
+
+ ```formatted
+ {
+ "key": "value"
+ }
+ ```
+
+ > 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 @@
+<?php
+
+use GeminiFoundation\Server;
+use GeminiFoundation\Server\RequestHandlers\DocumentServer;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$server = new Server(
+ [
+ 'file' => __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