aboutsummaryrefslogtreecommitdiffstats
path: root/gemini-proxy.php
diff options
context:
space:
mode:
authorsillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
committersillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
commit898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch)
tree85c6ee5ad58b860144551184d4cf86b560c62b91 /gemini-proxy.php
downloadwww-main.tar.gz
www-main.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to '')
-rw-r--r--gemini-proxy.php123
1 files changed, 123 insertions, 0 deletions
diff --git a/gemini-proxy.php b/gemini-proxy.php
new file mode 100644
index 0000000..6125c73
--- /dev/null
+++ b/gemini-proxy.php
@@ -0,0 +1,123 @@
+<?php
+/* gemini-proxy.php — Gemini protocol proxy for wired.os
+ Fetches a gemini:// URL and returns JSON { status, meta, body, url } */
+
+require_once __DIR__ . '/partials/proxy_helpers.php';
+
+header('Content-Type: application/json; charset=utf-8');
+header('Access-Control-Allow-Origin: *');
+
+if (!proxy_rate_limit('gemini', 30, 60)) {
+ http_response_code(429);
+ echo json_encode(['error' => 'rate limited']);
+ exit;
+}
+
+$url = trim($_GET['url'] ?? '');
+
+if (!$url) {
+ http_response_code(400);
+ echo json_encode(['error' => 'url required']);
+ exit;
+}
+
+if (!preg_match('#^gemini://#i', $url)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'gemini:// URLs only']);
+ exit;
+}
+
+if (strlen($url) > 1024) {
+ http_response_code(400);
+ echo json_encode(['error' => 'URL too long (max 1024)']);
+ exit;
+}
+
+$parsed = parse_url($url);
+$host = $parsed['host'] ?? '';
+$port = isset($parsed['port']) ? (int)$parsed['port'] : 1965;
+
+if (!$host) {
+ http_response_code(400);
+ echo json_encode(['error' => 'invalid host']);
+ exit;
+}
+
+if ($port < 1 || $port > 65535) {
+ http_response_code(400);
+ echo json_encode(['error' => 'invalid port']);
+ exit;
+}
+
+// SSRF guard: reject hosts that resolve to loopback / RFC1918 / link-local /
+// reserved ranges, so this endpoint can't be used to probe the internal network.
+if (!proxy_host_is_safe($host)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'host not allowed']);
+ exit;
+}
+
+$ctx = stream_context_create([
+ 'ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'allow_self_signed' => true,
+ ],
+]);
+
+$socket = @stream_socket_client(
+ "ssl://{$host}:{$port}",
+ $errno, $errstr, 10,
+ STREAM_CLIENT_CONNECT,
+ $ctx
+);
+
+if (!$socket) {
+ http_response_code(502);
+ echo json_encode(['error' => "connect failed ({$errno}): {$errstr}"]);
+ exit;
+}
+
+stream_set_timeout($socket, 10);
+fwrite($socket, $url . "\r\n");
+
+$raw = '';
+$cap = 4 * 1024 * 1024;
+while (!feof($socket)) {
+ $chunk = fread($socket, 8192);
+ if ($chunk === false) break;
+ $raw .= $chunk;
+ if (strlen($raw) >= $cap) { $raw = substr($raw, 0, $cap); break; }
+}
+fclose($socket);
+
+$eol = strpos($raw, "\r\n");
+if ($eol === false) {
+ http_response_code(502);
+ echo json_encode(['error' => 'malformed response (no CRLF)']);
+ exit;
+}
+
+$header = substr($raw, 0, $eol);
+$body = substr($raw, $eol + 2);
+
+if (!preg_match('#^(\d{2})\s*(.*)$#s', $header, $m)) {
+ http_response_code(502);
+ echo json_encode(['error' => 'malformed header line']);
+ exit;
+}
+
+$status = (int)$m[1];
+$meta = trim($m[2]);
+
+// Ensure body is valid UTF-8 for JSON encoding
+if (!mb_check_encoding($body, 'UTF-8')) {
+ $body = mb_convert_encoding($body, 'UTF-8', 'ISO-8859-1');
+}
+
+echo json_encode([
+ 'status' => $status,
+ 'meta' => $meta,
+ 'body' => $body,
+ 'url' => $url,
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);