Blame


1 2e64c739 2024-03-30 continue """Yet another http-to-gemini."""
2 2e64c739 2024-03-30 continue import socket
3 2e64c739 2024-03-30 continue import ssl
4 2e64c739 2024-03-30 continue import xml.etree.ElementTree as ET
5 2e64c739 2024-03-30 continue from argparse import ArgumentParser
6 2e64c739 2024-03-30 continue from email.message import Message
7 2e64c739 2024-03-30 continue from http import HTTPStatus
8 2e64c739 2024-03-30 continue from http.server import HTTPServer, BaseHTTPRequestHandler
9 2e64c739 2024-03-30 continue from urllib.parse import parse_qs, urlparse, urljoin, urlencode, uses_relative, uses_netloc
10 2e64c739 2024-03-30 continue from contextlib import contextmanager
11 2e64c739 2024-03-30 continue
12 2e64c739 2024-03-30 continue # for urljoin:
13 2e64c739 2024-03-30 continue uses_relative.append("gemini")
14 2e64c739 2024-03-30 continue uses_netloc.append("gemini")
15 2e64c739 2024-03-30 continue
16 2e64c739 2024-03-30 continue
17 2e64c739 2024-03-30 continue def _build_navigation(url=None):
18 2e64c739 2024-03-30 continue form = ET.Element("form")
19 2e64c739 2024-03-30 continue form.attrib.update(method="get")
20 2e64c739 2024-03-30 continue input_ = ET.SubElement(form, "input")
21 2e64c739 2024-03-30 continue input_.attrib.update(
22 2e64c739 2024-03-30 continue **{
23 2e64c739 2024-03-30 continue "title": "url",
24 2e64c739 2024-03-30 continue "type": "text",
25 2e64c739 2024-03-30 continue "name": "url",
26 2e64c739 2024-03-30 continue "placeholder": "gemini://",
27 2e64c739 2024-03-30 continue "autocomplete": "off",
28 2e64c739 2024-03-30 continue "size": "64",
29 2e64c739 2024-03-30 continue }
30 2e64c739 2024-03-30 continue )
31 2e64c739 2024-03-30 continue if url:
32 2e64c739 2024-03-30 continue input_.attrib.update(value=url)
33 2e64c739 2024-03-30 continue input_ = ET.SubElement(form, "input")
34 2e64c739 2024-03-30 continue input_.attrib.update(**{"type": "submit", "value": "go!"})
35 2e64c739 2024-03-30 continue return ET.tostring(form) + b"\r\n"
36 2e64c739 2024-03-30 continue
37 2e64c739 2024-03-30 continue
38 2e64c739 2024-03-30 continue class _HTTPServer(HTTPServer):
39 2e64c739 2024-03-30 continue def __init__(
40 2e64c739 2024-03-30 continue self,
41 2e64c739 2024-03-30 continue *args,
42 2e64c739 2024-03-30 continue header_file_path=None,
43 2e64c739 2024-03-30 continue footer_file_path=None,
44 2e64c739 2024-03-30 continue icon_file_path=None,
45 2e64c739 2024-03-30 continue css_file_path=None,
46 2e64c739 2024-03-30 continue **kwargs
47 2e64c739 2024-03-30 continue ):
48 2e64c739 2024-03-30 continue super().__init__(*args, **kwargs)
49 2e64c739 2024-03-30 continue with open(header_file_path, "rb") as f:
50 2e64c739 2024-03-30 continue self.header_file_bytes = f.read()
51 2e64c739 2024-03-30 continue with open(footer_file_path, "rb") as f:
52 2e64c739 2024-03-30 continue self.footer_file_bytes = f.read()
53 2e64c739 2024-03-30 continue with open(icon_file_path, "rb") as f:
54 2e64c739 2024-03-30 continue self.icon_file_bytes = f.read()
55 2e64c739 2024-03-30 continue with open(css_file_path, "rb") as f:
56 2e64c739 2024-03-30 continue self.css_file_bytes = f.read()
57 2e64c739 2024-03-30 continue
58 2e64c739 2024-03-30 continue
59 2e64c739 2024-03-30 continue class _Elem:
60 2e64c739 2024-03-30 continue def __init__(self, file):
61 2e64c739 2024-03-30 continue self.elem = None
62 8ad6b2ac 2024-04-12 continue self.file = file
63 2e64c739 2024-03-30 continue
64 2e64c739 2024-03-30 continue @contextmanager
65 2e64c739 2024-03-30 continue def __call__(self):
66 2e64c739 2024-03-30 continue yield self
67 2e64c739 2024-03-30 continue self.flush()
68 2e64c739 2024-03-30 continue
69 8ad6b2ac 2024-04-12 continue def flush_bytes(self):
70 8ad6b2ac 2024-04-12 continue if self.elem is None:
71 8ad6b2ac 2024-04-12 continue return b""
72 8ad6b2ac 2024-04-12 continue
73 8ad6b2ac 2024-04-12 continue rv = ET.tostring(self.elem) + b"\r\n"
74 8ad6b2ac 2024-04-12 continue self.elem = None
75 8ad6b2ac 2024-04-12 continue return rv
76 8ad6b2ac 2024-04-12 continue
77 2e64c739 2024-03-30 continue def flush(self):
78 8ad6b2ac 2024-04-12 continue self.file.write(self.flush_bytes())
79 2e64c739 2024-03-30 continue
80 2e64c739 2024-03-30 continue
81 8ad6b2ac 2024-04-12 continue class _FlushBeforeWrite:
82 2e64c739 2024-03-30 continue def __init__(self, elem):
83 2e64c739 2024-03-30 continue self._elem = elem
84 8ad6b2ac 2024-04-12 continue self._file = elem.file
85 2e64c739 2024-03-30 continue
86 2e64c739 2024-03-30 continue def cancel(self):
87 2e64c739 2024-03-30 continue self._elem = None
88 8ad6b2ac 2024-04-12 continue
89 8ad6b2ac 2024-04-12 continue def commit(self):
90 8ad6b2ac 2024-04-12 continue if self._elem is not None:
91 8ad6b2ac 2024-04-12 continue self._elem.flush()
92 8ad6b2ac 2024-04-12 continue self._elem = None
93 8ad6b2ac 2024-04-12 continue return self._file
94 2e64c739 2024-03-30 continue
95 2e64c739 2024-03-30 continue @contextmanager
96 2e64c739 2024-03-30 continue def __call__(self):
97 2e64c739 2024-03-30 continue yield self
98 8ad6b2ac 2024-04-12 continue self.commit()
99 2e64c739 2024-03-30 continue
100 2e64c739 2024-03-30 continue
101 2e64c739 2024-03-30 continue class _RequestHandler(BaseHTTPRequestHandler):
102 2e64c739 2024-03-30 continue def _parse_path(self):
103 2e64c739 2024-03-30 continue _, _, path, _, query, _ = urlparse(self.path)
104 2e64c739 2024-03-30 continue return path, parse_qs(query) if query else {}
105 2e64c739 2024-03-30 continue
106 2e64c739 2024-03-30 continue def do_GET(self):
107 2e64c739 2024-03-30 continue path, query = self._parse_path()
108 2e64c739 2024-03-30 continue if path in {"/index.html", "/index.htm", "/index", "/"}:
109 2e64c739 2024-03-30 continue url = query.get("url", [None])[0]
110 2e64c739 2024-03-30 continue if not url:
111 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
112 2e64c739 2024-03-30 continue self.send_header("Content-type", "text/html")
113 2e64c739 2024-03-30 continue self.end_headers()
114 2e64c739 2024-03-30 continue self.wfile.write(
115 2e64c739 2024-03-30 continue self.server.header_file_bytes.replace(b"$URL", b"yet another http-to-gemini")
116 2e64c739 2024-03-30 continue )
117 2e64c739 2024-03-30 continue self.wfile.write(_build_navigation())
118 2e64c739 2024-03-30 continue self.wfile.write(self.server.footer_file_bytes)
119 2e64c739 2024-03-30 continue return
120 2e64c739 2024-03-30 continue
121 2e64c739 2024-03-30 continue try:
122 4e5d1555 2024-04-11 continue for _ in range(6): # first request + 5 consecutive redirects
123 2e64c739 2024-03-30 continue parsed = urlparse(url)
124 2e64c739 2024-03-30 continue if parsed.scheme != "gemini":
125 2e64c739 2024-03-30 continue self.send_error(HTTPStatus.BAD_REQUEST, "Only gemini:// URLs are supported")
126 2e64c739 2024-03-30 continue return
127 2e64c739 2024-03-30 continue
128 2e64c739 2024-03-30 continue context = ssl.SSLContext()
129 2e64c739 2024-03-30 continue context.check_hostname = False
130 2e64c739 2024-03-30 continue context.verify_mode = ssl.CERT_NONE
131 2e64c739 2024-03-30 continue with socket.create_connection((parsed.hostname, parsed.port or 1965)) as raw_s:
132 401c2cd4 2024-04-01 continue with context.wrap_socket(raw_s, server_hostname=parsed.hostname) as s:
133 2e64c739 2024-03-30 continue s.sendall((url + '\r\n').encode("UTF-8"))
134 2e64c739 2024-03-30 continue fp = s.makefile("rb")
135 2e64c739 2024-03-30 continue splitted = fp.readline().decode("UTF-8").strip().split(maxsplit=1)
136 2e64c739 2024-03-30 continue status = splitted[0]
137 2e64c739 2024-03-30 continue if status.startswith("3") and len(splitted) == 2:
138 2e64c739 2024-03-30 continue # redirect
139 2e64c739 2024-03-30 continue url = urljoin(url, splitted[1])
140 2e64c739 2024-03-30 continue continue
141 2e64c739 2024-03-30 continue if not status.startswith("2"):
142 f07be084 2024-04-22 continue self.send_error(
143 f07be084 2024-04-22 continue HTTPStatus.INTERNAL_SERVER_ERROR,
144 f07be084 2024-04-22 continue f"Unsupported answer: {splitted[0]}",
145 f07be084 2024-04-22 continue splitted[1] if len(splitted) == 2 else None
146 f07be084 2024-04-22 continue )
147 2e64c739 2024-03-30 continue return
148 2e64c739 2024-03-30 continue mime = splitted[1].lower() if len(splitted) == 2 else "text/gemini"
149 2e64c739 2024-03-30 continue if not mime.startswith("text/gemini"):
150 2e64c739 2024-03-30 continue # return as-is
151 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
152 2e64c739 2024-03-30 continue self.send_header("Content-type", mime)
153 2e64c739 2024-03-30 continue self.end_headers()
154 2e64c739 2024-03-30 continue self.wfile.write(fp.read())
155 2e64c739 2024-03-30 continue return
156 2e64c739 2024-03-30 continue m = Message()
157 2e64c739 2024-03-30 continue m['content-type'] = mime
158 2e64c739 2024-03-30 continue body = fp.read().decode(m.get_param('charset') or "UTF-8")
159 2e64c739 2024-03-30 continue self._convert_gemini_to_html(url, body, mime)
160 2e64c739 2024-03-30 continue return
161 2e64c739 2024-03-30 continue break
162 2e64c739 2024-03-30 continue else:
163 2e64c739 2024-03-30 continue raise RuntimeError("Too many redirects")
164 2e64c739 2024-03-30 continue except Exception as error:
165 2e64c739 2024-03-30 continue self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, str(error))
166 2e64c739 2024-03-30 continue return
167 2e64c739 2024-03-30 continue
168 2e64c739 2024-03-30 continue if path == "/favicon.ico":
169 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
170 2e64c739 2024-03-30 continue self.send_header("Content-type", "image/x-icon")
171 2e64c739 2024-03-30 continue self.end_headers()
172 2e64c739 2024-03-30 continue self.wfile.write(self.server.icon_file_bytes)
173 2e64c739 2024-03-30 continue return
174 2e64c739 2024-03-30 continue
175 2e64c739 2024-03-30 continue if path == "/style.css":
176 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
177 2e64c739 2024-03-30 continue self.send_header("Content-type", "text/css")
178 2e64c739 2024-03-30 continue self.end_headers()
179 2e64c739 2024-03-30 continue self.wfile.write(self.server.css_file_bytes)
180 2e64c739 2024-03-30 continue return
181 2e64c739 2024-03-30 continue
182 2e64c739 2024-03-30 continue self.send_error(HTTPStatus.NOT_FOUND, "File not found")
183 2e64c739 2024-03-30 continue
184 2e64c739 2024-03-30 continue def _convert_gemini_to_html(self, url, body, mime):
185 2e64c739 2024-03-30 continue # convert gemini (body) to html
186 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
187 2e64c739 2024-03-30 continue self.send_header("Content-type", mime.replace("gemini", "html"))
188 2e64c739 2024-03-30 continue self.end_headers()
189 2e64c739 2024-03-30 continue self.wfile.write(self.server.header_file_bytes.replace(b"$URL", url.encode()))
190 2e64c739 2024-03-30 continue self.wfile.write(_build_navigation(url))
191 2e64c739 2024-03-30 continue with _Elem(self.wfile)() as pre:
192 2e64c739 2024-03-30 continue with _Elem(self.wfile)() as ul:
193 2e64c739 2024-03-30 continue for line in body.splitlines():
194 8ad6b2ac 2024-04-12 continue with _FlushBeforeWrite(ul)() as flush_before:
195 2e64c739 2024-03-30 continue if line.startswith("```"):
196 2e64c739 2024-03-30 continue if pre.elem is None:
197 2e64c739 2024-03-30 continue pre.elem = ET.Element("pre")
198 2e64c739 2024-03-30 continue pre.elem.text = ""
199 2e64c739 2024-03-30 continue else:
200 8ad6b2ac 2024-04-12 continue flush_before.commit().write(pre.flush_bytes())
201 2e64c739 2024-03-30 continue elif pre.elem is not None:
202 2e64c739 2024-03-30 continue if pre.elem.text:
203 2e64c739 2024-03-30 continue pre.elem.text += "\r\n"
204 2e64c739 2024-03-30 continue pre.elem.text += line
205 2e64c739 2024-03-30 continue elif line.startswith("=>") and line[2:].strip():
206 2e64c739 2024-03-30 continue p = ET.Element("p")
207 2e64c739 2024-03-30 continue p.text = "=> "
208 2e64c739 2024-03-30 continue splitted = line[2:].strip().split(maxsplit=1)
209 2e64c739 2024-03-30 continue target = urljoin(url, splitted[0])
210 2e64c739 2024-03-30 continue a = ET.SubElement(p, "a")
211 2e64c739 2024-03-30 continue if urlparse(target).scheme == "gemini":
212 2e64c739 2024-03-30 continue a.attrib.update(href="/?" + urlencode({"url": target}))
213 2e64c739 2024-03-30 continue else:
214 2e64c739 2024-03-30 continue a.attrib.update(target="_blank", href=target)
215 2e64c739 2024-03-30 continue a.text = splitted[1] if len(splitted) > 1 else target
216 8ad6b2ac 2024-04-12 continue flush_before.commit().write(ET.tostring(p) + b"\r\n")
217 2e64c739 2024-03-30 continue elif line.startswith("#") and len(line.split()) > 1:
218 2e64c739 2024-03-30 continue splitted = line.split(maxsplit=1)
219 2e64c739 2024-03-30 continue h = ET.Element("h" + str(len(splitted[0])))
220 2e64c739 2024-03-30 continue h.text = splitted[1]
221 8ad6b2ac 2024-04-12 continue flush_before.commit().write(ET.tostring(h) + b"\r\n")
222 2e64c739 2024-03-30 continue elif line.startswith("* ") and line[2:].strip():
223 2e64c739 2024-03-30 continue if ul.elem is None:
224 2e64c739 2024-03-30 continue ul.elem = ET.Element("ul")
225 2e64c739 2024-03-30 continue ET.SubElement(ul.elem, "li").text = line[2:].strip()
226 8ad6b2ac 2024-04-12 continue flush_before.cancel()
227 2e64c739 2024-03-30 continue elif line.startswith("> ") and line[2:].strip():
228 2e64c739 2024-03-30 continue blockquote = ET.Element("blockquote")
229 2e64c739 2024-03-30 continue ET.SubElement(blockquote, "p").text = line[2:].strip()
230 8ad6b2ac 2024-04-12 continue flush_before.commit().write(ET.tostring(blockquote) + b"\r\n")
231 2e64c739 2024-03-30 continue else:
232 2e64c739 2024-03-30 continue if line:
233 2e64c739 2024-03-30 continue p = ET.Element("p")
234 2e64c739 2024-03-30 continue p.text = line
235 8ad6b2ac 2024-04-12 continue flush_before.commit().write(ET.tostring(p) + b"\r\n")
236 2e0d560d 2024-03-30 continue self.wfile.write(self.server.footer_file_bytes)
237 2e64c739 2024-03-30 continue
238 2e64c739 2024-03-30 continue
239 2e64c739 2024-03-30 continue def _main():
240 2e64c739 2024-03-30 continue parser = ArgumentParser()
241 2e64c739 2024-03-30 continue parser.add_argument("--address", default="127.0.0.1", help="bind to this address (default: %(default)s)")
242 2e64c739 2024-03-30 continue parser.add_argument("--port", default=8000, type=int, help="bind to this port (default: %(default)s)")
243 2e64c739 2024-03-30 continue parser.add_argument("--header", required=True, help="path to `index.html.header`")
244 2e64c739 2024-03-30 continue parser.add_argument("--footer", required=True, help="path to `index.html.footer`")
245 2e64c739 2024-03-30 continue parser.add_argument("--icon", required=True , help="path to `favicon.ico`")
246 2e64c739 2024-03-30 continue parser.add_argument("--css", required=True, help="path to `style.css`")
247 2e64c739 2024-03-30 continue args = parser.parse_args()
248 2e64c739 2024-03-30 continue
249 2e64c739 2024-03-30 continue with _HTTPServer(
250 2e64c739 2024-03-30 continue (args.address, args.port),
251 2e64c739 2024-03-30 continue _RequestHandler,
252 2e64c739 2024-03-30 continue header_file_path=args.header,
253 2e64c739 2024-03-30 continue footer_file_path=args.footer,
254 2e64c739 2024-03-30 continue icon_file_path=args.icon,
255 2e64c739 2024-03-30 continue css_file_path=args.css,
256 2e64c739 2024-03-30 continue ) as http_server:
257 2e64c739 2024-03-30 continue sock_host, sock_port = http_server.socket.getsockname()[:2]
258 2e64c739 2024-03-30 continue print(f"HTTP server started ({sock_host}:{sock_port})...")
259 2e64c739 2024-03-30 continue try:
260 2e64c739 2024-03-30 continue http_server.serve_forever()
261 2e64c739 2024-03-30 continue except KeyboardInterrupt:
262 2e64c739 2024-03-30 continue print("\nKeyboard interrupt received, exiting.")
263 2e64c739 2024-03-30 continue
264 2e64c739 2024-03-30 continue
265 2e64c739 2024-03-30 continue if __name__ == '__main__':
266 2e64c739 2024-03-30 continue _main()