Skip to content

Instantly share code, notes, and snippets.

@Zegnat
Created August 15, 2021 19:20
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save Zegnat/4f1b2e024777e5bfeb5ad18ee41b35eb to your computer and use it in GitHub Desktop.
Add gemini:// support for file_get_contents(). Will turn into proper repo with Composer support later.
<?php declare(strict_types=1);
namespace Zegnat\Gemini;
/**
* @see https://www.php.net/manual/en/class.streamwrapper.php
*/
final class GeminiStreamWrapper {
/** @var resource $context if a stream context was provided it will be set here, even before __construct */
public $context;
/** @var resource $connection if a stream was successfully opened, this holds the pointer to the TLS stream */
private $connection;
private function __construct()
{
}
/**
* First thing to implement! Create connection.
*
* The STREAM_REPORT_ERRORS option is ignored, if something goes wrong trigger_error is always called.
* The STREAM_USE_PATH option is also ignored, gemini:// paths will always be absolute to trigger this wrapper.
* Because STREAM_USE_PATH is ignored, $opened_path is never assigned.
*/
public function stream_open(string $path, string $mode, int $options, string &$opened_path = null): bool {
// Made to match the HTTP wrapper (which for some reason allows c mode for reading)
if (($mode[0] !== 'r' && $mode[0] !== 'c') || \strchr($mode, '+') !== false) {
\trigger_error('fopen(' . $path . '): Failed to open stream: Gemini wrapper does not support writeable connections', \E_USER_WARNING);
return false;
}
$host = \parse_url($path, \PHP_URL_HOST);
$port = \parse_url($path, \PHP_URL_PORT) ?: 1965;
if (!is_string($host) || !is_int($port)) {
\trigger_error('fopen(' . $path . '): Failed to open stream: Provided path lacks a usable host/port', \E_USER_WARNING);
return false;
}
if (mb_strlen($path, '8bit') > 1024) {
\trigger_error('fopen(' . $path . '): Failed to open stream: Provided path cannot be bigger than 1024 bytes', \E_USER_WARNING);
return false;
}
// The TLS connection settings.
$context = \stream_context_create(['ssl' => [
'allow_self_signed' => true,
]]);
// C: Opens connection
// C: Validates server certificate
$this->connection = \stream_socket_client('tls://' . $host . ':' . $port, context: $context);
if ($this->connection === false) {
\trigger_error('fopen(' . $path . '): Failed to open stream: Socket could not be opened', \E_USER_WARNING);
return false;
}
// C: Sends request (one CRLF terminated line)
$writeSuccess = \fwrite($this->connection, $path . "\r\n");
if (false === $writeSuccess) {
$this->stream_close();
\trigger_error('fopen(' . $path . '): Failed to open stream: Request could not be send to the server', \E_USER_WARNING);
return false;
}
// Receive response header (one CRLF terminated line)
$header = \fgets($this->connection, 2 + 1 + 1024 + 2);
// If a server sends a <STATUS> which is not a two-digit number or a <META> which exceeds 1024 bytes in length, the client SHOULD close the connection and disregard the response header, informing the user of an error.
if (false === $header || \preg_match("/^\d\d\x20.{0,1024}\r\n$/", $header) !== 1) {
$this->stream_close();
\trigger_error('fopen(' . $path . '): Server returned an invalid response header', \E_USER_WARNING);
return false;
}
return true;
}
public function stream_read(int $count): string {
return \fread($this->connection, $count);
}
public function stream_eof(): bool {
return \feof($this->connection);
}
public function stream_close(): void {
\fclose($this->connection);
}
/**
* fstat is not supported for remote files, as per standard PHP.
* @see https://www.php.net/manual/en/function.fstat.php
*/
public function stream_stat(): bool {
return false;
}
}
if (!stream_wrapper_register('gemini', GeminiStreamWrapper::class, \STREAM_IS_URL)) {
throw new \RuntimeException('Could not register the Gemini streamWrapper.');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment