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 2e64c739 2024-03-30 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 2e64c739 2024-03-30 continue def flush(self):
70 2e64c739 2024-03-30 continue if self.elem is not None:
71 2e64c739 2024-03-30 continue self._file.write(ET.tostring(self.elem) + b"\r\n")
72 2e64c739 2024-03-30 continue self.elem = None
73 2e64c739 2024-03-30 continue
74 2e64c739 2024-03-30 continue
75 2e64c739 2024-03-30 continue class _AutoFlush:
76 2e64c739 2024-03-30 continue def __init__(self, elem):
77 2e64c739 2024-03-30 continue self._elem = elem
78 2e64c739 2024-03-30 continue
79 2e64c739 2024-03-30 continue def cancel(self):
80 2e64c739 2024-03-30 continue self._elem = None
81 2e64c739 2024-03-30 continue
82 2e64c739 2024-03-30 continue @contextmanager
83 2e64c739 2024-03-30 continue def __call__(self):
84 2e64c739 2024-03-30 continue yield self
85 2e64c739 2024-03-30 continue if self._elem is not None:
86 2e64c739 2024-03-30 continue self._elem.flush()
87 2e64c739 2024-03-30 continue
88 2e64c739 2024-03-30 continue
89 2e64c739 2024-03-30 continue class _RequestHandler(BaseHTTPRequestHandler):
90 2e64c739 2024-03-30 continue def _parse_path(self):
91 2e64c739 2024-03-30 continue _, _, path, _, query, _ = urlparse(self.path)
92 2e64c739 2024-03-30 continue return path, parse_qs(query) if query else {}
93 2e64c739 2024-03-30 continue
94 2e64c739 2024-03-30 continue def do_GET(self):
95 2e64c739 2024-03-30 continue path, query = self._parse_path()
96 2e64c739 2024-03-30 continue if path in {"/index.html", "/index.htm", "/index", "/"}:
97 2e64c739 2024-03-30 continue url = query.get("url", [None])[0]
98 2e64c739 2024-03-30 continue if not url:
99 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
100 2e64c739 2024-03-30 continue self.send_header("Content-type", "text/html")
101 2e64c739 2024-03-30 continue self.end_headers()
102 2e64c739 2024-03-30 continue self.wfile.write(
103 2e64c739 2024-03-30 continue self.server.header_file_bytes.replace(b"$URL", b"yet another http-to-gemini")
104 2e64c739 2024-03-30 continue )
105 2e64c739 2024-03-30 continue self.wfile.write(_build_navigation())
106 2e64c739 2024-03-30 continue self.wfile.write(self.server.footer_file_bytes)
107 2e64c739 2024-03-30 continue return
108 2e64c739 2024-03-30 continue
109 2e64c739 2024-03-30 continue try:
110 2e64c739 2024-03-30 continue for _ in range(32):
111 2e64c739 2024-03-30 continue parsed = urlparse(url)
112 2e64c739 2024-03-30 continue if parsed.scheme != "gemini":
113 2e64c739 2024-03-30 continue self.send_error(HTTPStatus.BAD_REQUEST, "Only gemini:// URLs are supported")
114 2e64c739 2024-03-30 continue return
115 2e64c739 2024-03-30 continue
116 2e64c739 2024-03-30 continue context = ssl.SSLContext()
117 2e64c739 2024-03-30 continue context.check_hostname = False
118 2e64c739 2024-03-30 continue context.verify_mode = ssl.CERT_NONE
119 2e64c739 2024-03-30 continue with socket.create_connection((parsed.hostname, parsed.port or 1965)) as raw_s:
120 401c2cd4 2024-04-01 continue with context.wrap_socket(raw_s, server_hostname=parsed.hostname) as s:
121 2e64c739 2024-03-30 continue s.sendall((url + '\r\n').encode("UTF-8"))
122 2e64c739 2024-03-30 continue fp = s.makefile("rb")
123 2e64c739 2024-03-30 continue splitted = fp.readline().decode("UTF-8").strip().split(maxsplit=1)
124 2e64c739 2024-03-30 continue status = splitted[0]
125 2e64c739 2024-03-30 continue if status.startswith("3") and len(splitted) == 2:
126 2e64c739 2024-03-30 continue # redirect
127 2e64c739 2024-03-30 continue url = urljoin(url, splitted[1])
128 2e64c739 2024-03-30 continue continue
129 2e64c739 2024-03-30 continue if not status.startswith("2"):
130 2e64c739 2024-03-30 continue self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "Unsupported answer: " + str(splitted))
131 2e64c739 2024-03-30 continue return
132 2e64c739 2024-03-30 continue mime = splitted[1].lower() if len(splitted) == 2 else "text/gemini"
133 2e64c739 2024-03-30 continue if not mime.startswith("text/gemini"):
134 2e64c739 2024-03-30 continue # return as-is
135 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
136 2e64c739 2024-03-30 continue self.send_header("Content-type", mime)
137 2e64c739 2024-03-30 continue self.end_headers()
138 2e64c739 2024-03-30 continue self.wfile.write(fp.read())
139 2e64c739 2024-03-30 continue return
140 2e64c739 2024-03-30 continue m = Message()
141 2e64c739 2024-03-30 continue m['content-type'] = mime
142 2e64c739 2024-03-30 continue body = fp.read().decode(m.get_param('charset') or "UTF-8")
143 2e64c739 2024-03-30 continue self._convert_gemini_to_html(url, body, mime)
144 2e64c739 2024-03-30 continue return
145 2e64c739 2024-03-30 continue break
146 2e64c739 2024-03-30 continue else:
147 2e64c739 2024-03-30 continue raise RuntimeError("Too many redirects")
148 2e64c739 2024-03-30 continue except Exception as error:
149 2e64c739 2024-03-30 continue self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, str(error))
150 2e64c739 2024-03-30 continue return
151 2e64c739 2024-03-30 continue
152 2e64c739 2024-03-30 continue if path == "/favicon.ico":
153 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
154 2e64c739 2024-03-30 continue self.send_header("Content-type", "image/x-icon")
155 2e64c739 2024-03-30 continue self.end_headers()
156 2e64c739 2024-03-30 continue self.wfile.write(self.server.icon_file_bytes)
157 2e64c739 2024-03-30 continue return
158 2e64c739 2024-03-30 continue
159 2e64c739 2024-03-30 continue if path == "/style.css":
160 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
161 2e64c739 2024-03-30 continue self.send_header("Content-type", "text/css")
162 2e64c739 2024-03-30 continue self.end_headers()
163 2e64c739 2024-03-30 continue self.wfile.write(self.server.css_file_bytes)
164 2e64c739 2024-03-30 continue return
165 2e64c739 2024-03-30 continue
166 2e64c739 2024-03-30 continue self.send_error(HTTPStatus.NOT_FOUND, "File not found")
167 2e64c739 2024-03-30 continue
168 2e64c739 2024-03-30 continue def _convert_gemini_to_html(self, url, body, mime):
169 2e64c739 2024-03-30 continue # convert gemini (body) to html
170 2e64c739 2024-03-30 continue self.send_response(HTTPStatus.OK)
171 2e64c739 2024-03-30 continue self.send_header("Content-type", mime.replace("gemini", "html"))
172 2e64c739 2024-03-30 continue self.end_headers()
173 2e64c739 2024-03-30 continue self.wfile.write(self.server.header_file_bytes.replace(b"$URL", url.encode()))
174 2e64c739 2024-03-30 continue self.wfile.write(_build_navigation(url))
175 2e64c739 2024-03-30 continue with _Elem(self.wfile)() as pre:
176 2e64c739 2024-03-30 continue with _Elem(self.wfile)() as ul:
177 2e64c739 2024-03-30 continue for line in body.splitlines():
178 2e64c739 2024-03-30 continue with _AutoFlush(ul)() as auto_flush_ul:
179 2e64c739 2024-03-30 continue if line.startswith("```"):
180 2e64c739 2024-03-30 continue if pre.elem is None:
181 2e64c739 2024-03-30 continue pre.elem = ET.Element("pre")
182 2e64c739 2024-03-30 continue pre.elem.text = ""
183 2e64c739 2024-03-30 continue else:
184 2e64c739 2024-03-30 continue pre.flush()
185 2e64c739 2024-03-30 continue elif pre.elem is not None:
186 2e64c739 2024-03-30 continue if pre.elem.text:
187 2e64c739 2024-03-30 continue pre.elem.text += "\r\n"
188 2e64c739 2024-03-30 continue pre.elem.text += line
189 2e64c739 2024-03-30 continue elif line.startswith("=>") and line[2:].strip():
190 2e64c739 2024-03-30 continue p = ET.Element("p")
191 2e64c739 2024-03-30 continue p.text = "=> "
192 2e64c739 2024-03-30 continue splitted = line[2:].strip().split(maxsplit=1)
193 2e64c739 2024-03-30 continue target = urljoin(url, splitted[0])
194 2e64c739 2024-03-30 continue a = ET.SubElement(p, "a")
195 2e64c739 2024-03-30 continue if urlparse(target).scheme == "gemini":
196 2e64c739 2024-03-30 continue a.attrib.update(href="/?" + urlencode({"url": target}))
197 2e64c739 2024-03-30 continue else:
198 2e64c739 2024-03-30 continue a.attrib.update(target="_blank", href=target)
199 2e64c739 2024-03-30 continue a.text = splitted[1] if len(splitted) > 1 else target
200 2e64c739 2024-03-30 continue self.wfile.write(ET.tostring(p) + b"\r\n")
201 2e64c739 2024-03-30 continue elif line.startswith("#") and len(line.split()) > 1:
202 2e64c739 2024-03-30 continue splitted = line.split(maxsplit=1)
203 2e64c739 2024-03-30 continue h = ET.Element("h" + str(len(splitted[0])))
204 2e64c739 2024-03-30 continue h.text = splitted[1]
205 2e64c739 2024-03-30 continue self.wfile.write(ET.tostring(h) + b"\r\n")
206 2e64c739 2024-03-30 continue elif line.startswith("* ") and line[2:].strip():
207 2e64c739 2024-03-30 continue if ul.elem is None:
208 2e64c739 2024-03-30 continue ul.elem = ET.Element("ul")
209 2e64c739 2024-03-30 continue ET.SubElement(ul.elem, "li").text = line[2:].strip()
210 2e64c739 2024-03-30 continue auto_flush_ul.cancel()
211 2e64c739 2024-03-30 continue elif line.startswith("> ") and line[2:].strip():
212 2e64c739 2024-03-30 continue blockquote = ET.Element("blockquote")
213 2e64c739 2024-03-30 continue ET.SubElement(blockquote, "p").text = line[2:].strip()
214 2e64c739 2024-03-30 continue self.wfile.write(ET.tostring(blockquote) + b"\r\n")
215 2e64c739 2024-03-30 continue else:
216 2e64c739 2024-03-30 continue if line:
217 2e64c739 2024-03-30 continue p = ET.Element("p")
218 2e64c739 2024-03-30 continue p.text = line
219 2e64c739 2024-03-30 continue self.wfile.write(ET.tostring(p) + b"\r\n")
220 2e0d560d 2024-03-30 continue self.wfile.write(self.server.footer_file_bytes)
221 2e64c739 2024-03-30 continue
222 2e64c739 2024-03-30 continue
223 2e64c739 2024-03-30 continue def _main():
224 2e64c739 2024-03-30 continue parser = ArgumentParser()
225 2e64c739 2024-03-30 continue parser.add_argument("--address", default="127.0.0.1", help="bind to this address (default: %(default)s)")
226 2e64c739 2024-03-30 continue parser.add_argument("--port", default=8000, type=int, help="bind to this port (default: %(default)s)")
227 2e64c739 2024-03-30 continue parser.add_argument("--header", required=True, help="path to `index.html.header`")
228 2e64c739 2024-03-30 continue parser.add_argument("--footer", required=True, help="path to `index.html.footer`")
229 2e64c739 2024-03-30 continue parser.add_argument("--icon", required=True , help="path to `favicon.ico`")
230 2e64c739 2024-03-30 continue parser.add_argument("--css", required=True, help="path to `style.css`")
231 2e64c739 2024-03-30 continue args = parser.parse_args()
232 2e64c739 2024-03-30 continue
233 2e64c739 2024-03-30 continue with _HTTPServer(
234 2e64c739 2024-03-30 continue (args.address, args.port),
235 2e64c739 2024-03-30 continue _RequestHandler,
236 2e64c739 2024-03-30 continue header_file_path=args.header,
237 2e64c739 2024-03-30 continue footer_file_path=args.footer,
238 2e64c739 2024-03-30 continue icon_file_path=args.icon,
239 2e64c739 2024-03-30 continue css_file_path=args.css,
240 2e64c739 2024-03-30 continue ) as http_server:
241 2e64c739 2024-03-30 continue sock_host, sock_port = http_server.socket.getsockname()[:2]
242 2e64c739 2024-03-30 continue print(f"HTTP server started ({sock_host}:{sock_port})...")
243 2e64c739 2024-03-30 continue try:
244 2e64c739 2024-03-30 continue http_server.serve_forever()
245 2e64c739 2024-03-30 continue except KeyboardInterrupt:
246 2e64c739 2024-03-30 continue print("\nKeyboard interrupt received, exiting.")
247 2e64c739 2024-03-30 continue
248 2e64c739 2024-03-30 continue
249 2e64c739 2024-03-30 continue if __name__ == '__main__':
250 2e64c739 2024-03-30 continue _main()