config = new Config(); } else { $this->config = $config; } if ($logger !== null) { $this->setLogger($logger); } else { $this->getLogger(); } if ($cert == null) { $this->cert = new Cert($config, $this->getLogger()); } else { $this->cert = $cert; } $this->timeout = ini_get("default_socket_timeout"); $this->ssl_context = $this->createSslContext(); } public function setLogger(Logger $logger) { $this->logger = $logger; } public function getLogger() { if (!$this->logger) { $this->logger = new Logger('orbit'); $this->logger->pushHandler(new StreamHandler($this->config->log_file, Logger::INFO)); } return $this->logger; } public function listen($root_dir = null) { if (null == $root_dir) { $root_dir = $this->config->root_dir; } if (!is_dir($root_dir)) { throw new \Exception("Error: Root directory '$root_dir' not a directory"); } $path = realpath($root_dir); $this->logger->debug("Root directory '$path'"); $server = stream_socket_server( $this->getListenAddress(), $errno, $errstr, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN, $this->ssl_context ); if (!$server) { throw new \Exception("Error " . $errno . ": " . $errstr); } $protocol = "gemini"; $name = stream_socket_get_name($server, false); $this->logger->info("Listening on $protocol://$name ..."); while (true) { # This is to swallow up the `timeout` warning set_error_handler([$this, 'onWarning']); $client = stream_socket_accept($server, $this->timeout, $client_name); restore_error_handler(); if ($client) { $time = ['start' => microtime(true)]; $this->logger->debug("$client_name Accepted"); $request_buffer = stream_get_line($client, 1024, "\r\n"); $this->logger->info("REQ: $request_buffer", ["client" => $client_name]); $request = new Request($request_buffer); // Respond to client $response = $this->handleResponse($request, $path); $size = $response->send($client); $time['end'] = microtime(true); $this->logger->debug( "RSP: " . trim($response->getHeader()), ['size' => $size, 'time' => $time['end'] - $time['start']] ); fclose($client); $this->logger->debug("$client_name Closed"); } } } public function handleResponse($request, $dir) { $resource_path = rtrim($dir, "/") . $request->path; $response = new Response(); if (is_dir($resource_path)) { // If missing the final slash, issue a redirect if ($resource_path[-1] != "/") { $response->setStatus(Response::STATUS_REDIRECT_PERMANENT); $response->setMeta($request->getUrlAppendPath('/')); return $response; } // Check if index file exists if (file_exists($resource_path . DIRECTORY_SEPARATOR . $this->config->index_file)) { $resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file; } else { if (!$this->config->enable_directory_index) { $response->setStatus(Response::STATUS_BAD_REQUEST); $response->setMeta('Path not available'); return $response; } else { $response->setStatus(Response::STATUS_SUCCESS); $response->setMeta('text/gemini'); $response->setBody($this->makeDirectoryIndex($resource_path, $dir)); return $response; } } } // File exists and is world readable if (file_exists($resource_path) && (fileperms($resource_path) & 0x0004)) { $response->setStatus(Response::STATUS_SUCCESS); $pathinfo = pathinfo($resource_path); if (!isset($pathinfo['extension'])) { $response->setStatus(Response::STATUS_TEMPORARY_FAILURE); $response->setMeta('Error reading resource'); return $response; } if ($pathinfo['extension'] == 'gmi' || $pathinfo['extension'] == 'gemini') { $response->setMeta('text/gemini'); } elseif ($pathinfo['extension'] == 'md' || $pathinfo['extension'] == 'markdown') { $response->setMeta('text/gemini'); } else { $response->setMeta(mime_content_type($resource_path)); } $response->setStaticFile($resource_path); } else { $response->setStatus(Response::STATUS_NOT_FOUND); $response->setMeta('Not found!'); } return $response; } public function log($level, $message, $context = []) { $this->getLogger()->log($level, $message, $context); } public function onWarning($id, $message) { if (strpos($message, "timed out") !== false) { // Do nothing return; } // Something else happened. $error = sprintf("Error %s: %s", $id, $message); $this->logger->error($error); throw new \Exception($error); } public function getListenAddress() { return sprintf('tls://%s:%s', $this->config->host, $this->config->port); } public function makeDirectoryIndex($path, $root) { $files = glob($path . "*"); $body = "# Directory listing " . str_replace($root, '', $path) . "\n\n"; $body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n"; foreach ($files as $file) { $relative_path = str_replace($path, '', $file); $is_dir = false; if (is_dir($file)) { $is_dir = true; $size = ''; } else { $size = filesize($file); } $body .= sprintf( "=> %s%s %s%s%s\n", urlencode($relative_path), ($is_dir ? '/' : ''), $relative_path, ($is_dir ? '/' : ''), ($size ? " ($size)" : '') ); } return $body; } public function createSslContext() { $context = stream_context_create(); // local_cert must be in PEM format stream_context_set_option($context, 'ssl', 'local_cert', $this->cert->tls_certfile); stream_context_set_option($context, 'ssl', 'local_pk', $this->cert->tls_keyfile); stream_context_set_option($context, 'ssl', 'passphrase', $this->cert->key_passphrase); stream_context_set_option($context, 'ssl', 'allow_self_signed', true); stream_context_set_option($context, 'ssl', 'verify_peer', false); return $context; } }