aboutsummaryrefslogtreecommitdiffstats
path: root/gemini-proxy.php
blob: 6125c730d6eff5b8c565ca55f2ae48b598f7b387 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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);