diff options
| author | Daniel Weipert <code@drogueronin.de> | 2023-11-12 11:16:56 +0100 | 
|---|---|---|
| committer | Daniel Weipert <code@drogueronin.de> | 2023-11-20 17:30:04 +0100 | 
| commit | 6df3d321d9b67c4541f50158b087d37c4b22e886 (patch) | |
| tree | 06031988bb4b957b76f051af366ced448a48545f | |
initial commit
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Justfile | 5 | ||||
| -rw-r--r-- | Readme.md | 3 | ||||
| -rw-r--r-- | composer.json | 18 | ||||
| -rw-r--r-- | composer.lock | 18 | ||||
| -rw-r--r-- | docker-compose.yml | 9 | ||||
| -rw-r--r-- | docker/server/Dockerfile | 13 | ||||
| -rw-r--r-- | docker/server/server.php | 19 | ||||
| -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 | ||||
| -rw-r--r-- | test/client.php | 3 | ||||
| -rw-r--r-- | test/gemtext.php | 27 | ||||
| -rw-r--r-- | test/index.gmi | 17 | ||||
| -rw-r--r-- | test/server.php | 18 | ||||
| -rw-r--r-- | test/sub/index.gmi | 3 | ||||
| -rw-r--r-- | test/test.gmi | 4 | 
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  | 
