2 from json import loads as json_loads
3 from mimetypes import guess_type
5 from pathlib import Path
6 from sys import stdout, stderr
7 from sqlite3 import connect as sqlite3_connect
8 from time import clock_gettime, CLOCK_MONOTONIC
9 from urllib.error import HTTPError, URLError
10 from urllib.parse import urlencode, urlunsplit, urljoin, urlsplit, parse_qsl, unquote, quote
11 from urllib.request import urlopen
12 from html.parser import HTMLParser
16 def __init__(self, tag, attrs):
19 def on_data(self, data):
20 raise AssertionError("The method must be overridden")
23 raise AssertionError("The method must be overridden")
26 class ParagraphTag(_BaseTag):
27 def __init__(self, tag, attrs):
28 super().__init__(tag, attrs)
32 def on_data(self, data):
33 self.content.append(data.strip())
36 result = " ".join(" ".join(data.split()) for data in self.content if data)
40 return "\n".join([result] + footer) + "\n" if result else ""
43 class LinkTag(_BaseTag):
44 def __init__(self, href, paragraph, tag, attrs):
45 super().__init__(tag, attrs)
47 self.paragraph = paragraph
50 def on_data(self, data):
52 self.paragraph.on_data("↳")
53 self.paragraph.on_data(data)
54 self.content.append(data.strip())
57 text = " ".join(" ".join(data.split()) for data in self.content if data)
58 self.paragraph.footer.append(f"=> {self.href} {text}")
62 class LitItemTag(ParagraphTag):
64 content = super().flush()
65 return f"* {content}" if content else ""
68 class QuoteTag(ParagraphTag):
70 content = super().flush()
71 return f"> {content}" if content else ""
74 class HeaderTag(ParagraphTag):
76 content = super().flush()
79 return f"{'#' * int(self.tag[1:])} {content}"
82 class PreformattedTag(_BaseTag):
83 def __init__(self, tag, attrs):
84 super().__init__(tag, attrs)
87 def on_data(self, data):
93 return f"```\n{result}\n```\n" if result else ""
96 class HtmlToGmi(HTMLParser):
97 def __init__(self, base_url, fn_media_url):
101 self.base_url = base_url
102 self.fn_media_url = fn_media_url
104 def feed(self, data):
107 self.gmi_text.append(self.stack.pop().flush())
108 return "\n".join(gmi_text for gmi_text in self.gmi_text if gmi_text)
110 def handle_starttag(self, tag, attrs):
113 self.gmi_text.append(self.stack[-1].flush())
114 self.stack.append(elem)
117 _push(ParagraphTag(tag, attrs))
119 _push(PreformattedTag(tag, attrs))
120 elif tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
121 _push(HeaderTag(tag, attrs))
122 elif tag in {"li", "dt"}:
123 _push(LitItemTag(tag, attrs))
124 elif tag in {"blockquote", "q"}:
125 _push(QuoteTag(tag, attrs))
127 href = dict(attrs).get("href")
129 self.stack.append(LinkTag(urljoin(self.base_url, href), self._get_current_paragraph(), tag, attrs))
132 title = img.get("title") or ""
133 if img.get("class") == "emu" and title and self.stack:
134 self.stack[-1].on_data(title)
138 img_url = urljoin(self.base_url, src)
139 mime, _ = guess_type(img_url)
140 img_url = self.fn_media_url(mime, img_url)
141 self.gmi_text.append(f"=> {img_url} {title or img_url}")
144 self.gmi_text.append(self.stack[-1].flush())
146 def handle_data(self, data):
148 self.stack.append(ParagraphTag("p", []))
149 self.stack[-1].on_data(data)
151 def handle_endtag(self, tag):
152 if self.stack and tag == self.stack[-1].tag:
153 self.gmi_text.append(self.stack.pop().flush())
155 def _get_current_paragraph(self):
156 for elem in reversed(self.stack):
157 if isinstance(elem, ParagraphTag):
160 self.stack = [ParagraphTag("p", [])] + self.stack
165 def __init__(self, raw_url):
166 self._splitted_url = urlsplit(raw_url)
167 self.splitted_path = [part for part in self._splitted_url.path.split("/") if part]
168 self.page = self.splitted_path[-1]
170 for path_part in self.splitted_path:
171 self._base_path.append(path_part)
172 if path_part == "lonk":
175 def build(self, page, query=""):
176 page = page if isinstance(page, list) else [page]
178 ("gemini", self._splitted_url.netloc, "/".join(self._base_path + page), query, "")
181 def media(self, mime, url):
182 return self.build("proxy", urlencode({"m": mime, "u": url})) if mime else url
186 return self._splitted_url.query
190 def __init__(self, raw_url, token):
191 self._splitted_url = urlsplit(raw_url)
194 def build(self, scheme=None, netloc=None, path="", query="", fragment=""):
197 scheme or self._splitted_url.scheme,
198 netloc or self._splitted_url.netloc,
205 def get(self, action, answer_is_json=True, **kwargs):
206 query = {**{"action": action, "token": self._token}, **kwargs}
207 with urlopen(self.build(path="api", query=urlencode(query)), timeout=45) as response:
208 answer = response.read().decode("utf8")
209 return json_loads(answer) if answer_is_json else answer
212 def _create_schema(db_con):
217 client_id INTEGER PRIMARY KEY,
218 cert_hash TEXT UNIQUE,
219 honk_url TEXT NOT NULL,
228 convoy_id INTEGER PRIMARY KEY,
241 FOREIGN KEY (client_id) REFERENCES client (client_id),
242 UNIQUE (convoy, client_id),
243 UNIQUE (honk_id, client_id)
251 donk_id INTEGER PRIMARY KEY,
257 FOREIGN KEY (client_id) REFERENCES client (client_id),
258 FOREIGN KEY (convoy_id) REFERENCES convoy (convoy_id)
266 xonker_id INTEGER PRIMARY KEY,
270 FOREIGN KEY (client_id) REFERENCES client (client_id),
271 FOREIGN KEY (convoy_id) REFERENCES convoy (convoy_id)
279 db_schema_version INTEGER
283 db_con.execute("INSERT INTO meta(db_schema_version) VALUES (?)", (1, ))
287 db_file_path = Path(__file__).parent / ".local" / "db"
288 db_file_path.parent.mkdir(parents=True, exist_ok=True)
289 db_exist = db_file_path.exists()
290 db_con = sqlite3_connect(db_file_path)
293 _create_schema(db_con)
298 def __init__(self, convoy_id, convoy, honk_id, handle, oondle, url, html, date, public, handles, honker, oonker):
299 self.convoy_id = convoy_id
301 self.honk_id = honk_id
307 self.public = bool(public)
308 self.handles = handles
314 def iterate_honks(self):
315 if self.html is not None:
317 "Convoy": self.convoy,
318 "Handle": self.handle,
319 "Oondle": self.oondle,
324 "Public": self.public,
325 "Handles": self.handles,
326 "Honker": self.honker,
327 "Oonker": self.oonker,
329 {"URL": donk[0], "Media": donk[1], "Desc": donk[2]}
330 for donk in self.donks
333 child_honks = self.thread[:]
334 child_honks.reverse()
335 yield from reversed(child_honks)
338 def page_lonk(db_con, client_id, lonk_url, honk_url):
339 gethonks_answer = honk_url.get("gethonks", page="home")
342 for honk in reversed(gethonks_answer.pop("honks", None) or []):
343 convoy = honk["Convoy"]
345 if convoy not in lonk_page:
346 row = db_con.execute(
349 convoy_id, convoy, honk_id, handle, oondle, url, html, date, public, handles, honker_url, oonker_url
353 client_id=? AND convoy=?
358 lonk_page[convoy] = _LonkTreeItem(*row)
359 res_donks = db_con.execute(
360 "SELECT url, mime, alt_text FROM donk WHERE client_id=? AND convoy_id=?",
361 (client_id, lonk_page[convoy].convoy_id, )
364 donks = res_donks.fetchmany()
369 donk_url, donk_mime, donk_text = donk
370 lonk_page[convoy].donks.append((donk_url, donk_mime, donk_text))
372 if convoy not in lonk_page:
373 def _save_convoy(convoy, honk):
374 is_public = 1 if honk.get("Public") else 0
375 row = db_con.execute(
378 convoy(convoy, client_id, honk_id, handle, oondle, url, html, date, public, handles, honker_url, oonker_url)
380 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
382 convoy_id, convoy, honk_id, handle, oondle, url, html, date, public, handles, honker_url, oonker_url
384 (convoy, client_id, honk["ID"], honk["Handle"], honk.get("Oondle"), honk["XID"], honk["HTML"], honk["Date"], is_public, honk["Handles"], honk.get("Honker"), honk.get("Oonker"))
386 lonk_page[convoy] = _LonkTreeItem(*row)
388 for donk in (honk.get("Donks") or []):
389 donk_url = honk_url.build(path=f'/d/{donk["XID"]}') if donk.get("XID") else donk["URL"]
390 donk_mime, donk_text = donk["Media"], donk.get("Desc") or donk.get("Name") or None
392 "INSERT INTO donk (client_id, convoy_id, url, mime, alt_text) VALUES (?, ?, ?, ?, ?)",
393 (client_id, lonk_page[convoy].convoy_id, donk_url, donk_mime, donk_text, )
395 lonk_page[convoy].donks.append((donk_url, donk_mime, donk_text))
398 for honk_in_convoy in honk_url.get("gethonks", page="convoy", c=convoy)["honks"]:
399 if not honk_in_convoy.get("RID"):
400 _save_convoy(convoy, honk_in_convoy)
403 _save_convoy(convoy, {"ID": None, "Handle": None, "XID": None, "HTML": None, "Date": None, "Handles": None})
405 _save_convoy(convoy, honk)
408 lonk_page[convoy].thread.append(honk)
410 gethonks_answer["honks"] = []
411 for tree_item in reversed(lonk_page.values()):
412 gethonks_answer["honks"] += list(tree_item.iterate_honks())
414 print("20 text/gemini\r")
415 print("# 𝓗 onk: lonk\r")
417 print_gethonks(gethonks_answer, lonk_url, honk_url)
420 def page_convoy(db_con, client_id, lonk_url, honk_url):
421 query = {pair[0]: pair[1] for pair in parse_qsl(lonk_url.query)}
423 print("51 Not found\r")
426 gethonks_answer = honk_url.get("gethonks", page="convoy", c=query["c"])
427 print("20 text/gemini\r")
428 print(f"# 𝓗 onk: convoy {query['c']}\r")
430 print_gethonks(gethonks_answer, lonk_url, honk_url)
433 def page_search(db_con, client_id, lonk_url, honk_url):
434 if not lonk_url.query:
435 print("10 What are we looking for?\r")
438 q = unquote(lonk_url.query)
439 gethonks_answer = honk_url.get("gethonks", page="search", q=q)
440 print("20 text/gemini\r")
441 print(f"# 𝓗 onk: search - {q}\r")
443 print_gethonks(gethonks_answer, lonk_url, honk_url)
446 def page_atme(db_con, client_id, lonk_url, honk_url):
447 gethonks_answer = honk_url.get("gethonks", page="atme")
448 print("20 text/gemini\r")
449 print("# 𝓗 onk: @me")
451 print_gethonks(gethonks_answer, lonk_url, honk_url)
454 def page_longago(db_con, client_id, lonk_url, honk_url):
455 gethonks_answer = honk_url.get("gethonks", page="longago")
456 print("20 text/gemini\r")
457 print("# 𝓗 onk: long ago")
459 print_gethonks(gethonks_answer, lonk_url, honk_url)
462 def page_myhonks(db_con, client_id, lonk_url, honk_url):
463 gethonks_answer = honk_url.get("gethonks", page="myhonks")
464 print("20 text/gemini\r")
465 print("# 𝓗 onk: my honks")
467 print_gethonks(gethonks_answer, lonk_url, honk_url)
470 def page_honker(db_con, client_id, lonk_url, honk_url):
471 xid = {pair[0]: pair[1] for pair in parse_qsl(lonk_url.query)}.get("xid")
473 print("51 Not found\r")
476 gethonks_answer = honk_url.get("gethonks", page="honker", xid=xid)
477 print("20 text/gemini\r")
478 print(f"# 𝓗 onk: honks of {xid}\r")
480 print_gethonks(gethonks_answer, lonk_url, honk_url)
483 def menu(lonk_url, honk_url, gethonks_answer=None):
484 print(f"## 📝 Menu\r")
485 print(f"=> {lonk_url.build('newhonk')} new honk\r")
486 print(f"=> {lonk_url.build([])} lonk home\r")
489 line = f"=> {lonk_url.build('atme')} @me"
490 if gethonks_answer["mecount"]:
491 line += f' ({gethonks_answer["mecount"]})'
494 line = f"=> {honk_url.build(path='chatter')} chatter"
495 if gethonks_answer["chatcount"]:
496 line += f' ({gethonks_answer["chatcount"]})'
499 print(f"=> {lonk_url.build('search')} search\r")
500 print(f"=> {lonk_url.build('longago')} long ago\r")
501 print(f"=> {lonk_url.build('myhonks')} my honks\r")
502 print(f"=> {lonk_url.build('gethonkers')} honkers\r")
503 print(f"=> {lonk_url.build('addhonker')} add new honker\r")
506 def print_gethonks(gethonks_answer, lonk_url, honk_url):
507 menu(lonk_url, honk_url, gethonks_answer)
510 for honk in gethonks_answer.get("honks") or []:
511 convoy = honk["Convoy"]
512 re_url = honk.get("RID")
513 oondle = honk.get("Oondle")
514 from_ = f'{oondle} (🔁 {honk["Handle"]})' if oondle else f'{honk["Handle"]}'
516 f'##{"# ↱" if re_url else ""} From {from_} {honk["Date"]}',
517 f'=> {lonk_url.build("convoy", urlencode({"c": convoy}))} Convoy {convoy}',
521 lines.append(f'=> {re_url} Re: {re_url}')
523 lines.append(HtmlToGmi(honk_url.build(), lonk_url.media).feed(honk["HTML"]))
524 for donk in honk.get("Donks") or []:
525 donk_url = honk_url.build(path=f'/d/{donk["XID"]}') if donk.get("XID") else donk["URL"]
526 donk_mime, donk_text = donk["Media"], donk.get("Desc") or donk.get("Name") or None
527 lines.append(f'=> {lonk_url.media(donk_mime, donk_url)} {donk_url}')
529 lines.append(donk_text)
531 if honk.get("Public"):
532 lines.append(f'=> {lonk_url.build("bonk", urlencode({"w": honk["XID"]}))} ↺ bonk')
533 honk_back_url = lonk_url.build(
535 quote(honk["Handles"] or " ", safe=""),
536 quote(honk["XID"], safe=""),
540 lines.append(f'=> {honk_back_url} ↱ honk back')
541 for xonker in (honk.get("Honker"), honk.get("Oonker")):
543 lines.append(f'=> {lonk_url.build("honker", urlencode({"xid": xonker}))} honks of {xonker}')
544 print("\r\n".join(lines))
547 if gethonks_answer.get("honks"):
549 menu(lonk_url, honk_url, gethonks_answer)
552 def new_client_stage_1_ask_server(lonk_url):
553 if not lonk_url.query:
554 print("10 Honk server URL\r")
556 splitted = urlsplit(unquote(lonk_url.query))
557 path = [quote(urlunsplit((splitted.scheme, splitted.netloc, "", "", "")), safe=""), "ask_username"]
558 print(f'30 {lonk_url.build(path)}\r')
561 def new_client_stage_2_ask_username(lonk_url):
562 if not lonk_url.query:
563 print("10 Honk user name\r")
565 if len(lonk_url.splitted_path) < 3:
566 print('59 Bad request\r')
568 quoted_server = lonk_url.splitted_path[-2]
569 path = [quoted_server, quote(unquote(lonk_url.query), safe=""), "ask_password"]
570 print(f'30 {lonk_url.build(path)}\r')
573 def new_client_stage_3_ask_password(cert_hash, lonk_url):
574 if not lonk_url.query:
575 print("11 Honk user password\r")
577 if len(lonk_url.splitted_path) < 4:
578 print('59 Bad request\r')
581 honk_url = unquote(lonk_url.splitted_path[-3])
583 "username": unquote(lonk_url.splitted_path[-2]),
584 "password": unquote(lonk_url.query),
587 with urlopen(honk_url + "/dologin", data=urlencode(post_data).encode(), timeout=15) as response:
588 token = response.read().decode("utf8")
589 db_con = db_connect()
592 "INSERT INTO client (cert_hash, honk_url, token) VALUES (?, ?, ?)",
593 (cert_hash, honk_url, token)
595 print(f'30 {lonk_url.build([])}\r')
598 def bonk(db_con, client_id, lonk_url, honk_url):
599 what = {pair[0]: pair[1] for pair in parse_qsl(lonk_url.query)}.get("w")
601 print("51 Not found\r")
604 honk_url.get("zonkit", wherefore="bonk", what=what, answer_is_json=False)
605 print(f'30 {lonk_url.build("myhonks")}\r')
608 def gethonkers(db_con, client_id, lonk_url, honk_url):
609 print("20 text/gemini\r")
610 print("# 𝓗 onk: honkers\r")
613 menu(lonk_url, honk_url)
616 honkers = honk_url.get("gethonkers").get("honkers") or []
617 for honker in honkers:
618 print(f'## {honker.get("Name") or honker["XID"]}\r')
619 for field_name, display_name in zip(("Name", "XID", "Flavor"), ("name", "url", "flavor")):
620 value = honker.get(field_name)
622 print(f'{display_name}: {value}\r')
623 if honker.get("Flavor") == "sub":
624 print(f'=> {lonk_url.build("unsubscribe", urlencode({"honkerid": honker["ID"]}))} unsubscribe\r')
626 print(f'=> {lonk_url.build("subscribe", urlencode({"honkerid": honker["ID"]}))} (re)subscribe\r')
631 menu(lonk_url, honk_url)
634 def addhonker(db_con, client_id, lonk_url, honk_url):
635 if not lonk_url.query:
636 print("10 honker url: \r")
639 url = unquote(lonk_url.query)
641 honk_url.get("savehonker", url=url, answer_is_json=False)
642 print(f'30 {lonk_url.build("gethonkers")}\r')
643 except HTTPError as error:
644 print("20 text/gemini\r")
645 print("# 𝓗 onk: add new honker\r")
647 menu(lonk_url, honk_url)
650 print(f'> {error.fp.read().decode("utf8")}\r')
653 def unsubscribe(db_con, client_id, lonk_url, honk_url):
654 honkerid = {pair[0]: pair[1] for pair in parse_qsl(lonk_url.query)}.get("honkerid")
656 print("51 Not found\r")
659 url = unquote(lonk_url.query)
660 honk_url.get("savehonker", honkerid=honkerid, unsub="unsub", answer_is_json=False)
661 print(f'30 {lonk_url.build("gethonkers")}\r')
664 def subscribe(db_con, client_id, lonk_url, honk_url):
665 honkerid = {pair[0]: pair[1] for pair in parse_qsl(lonk_url.query)}.get("honkerid")
667 print("51 Not found\r")
670 url = unquote(lonk_url.query)
671 honk_url.get("savehonker", honkerid=honkerid, sub="sub", answer_is_json=False)
672 print(f'30 {lonk_url.build("gethonkers")}\r')
675 def newhonk(db_con, client_id, lonk_url, honk_url):
676 if not lonk_url.query:
677 print("10 let's make some noise: \r")
680 noise = unquote(lonk_url.query)
681 honk_url.get("honk", noise=noise, answer_is_json=False)
682 print(f'30 {lonk_url.build("myhonks")}\r')
685 def honkback(db_con, client_id, lonk_url, honk_url):
686 if not lonk_url.query:
687 handles = unquote(lonk_url.splitted_path[-3]).strip()
688 rid = unquote(lonk_url.splitted_path[-2])
689 print(f"10 Answer to {handles or rid}:\r")
692 noise = unquote(lonk_url.query)
693 rid = unquote(lonk_url.splitted_path[-2])
694 honk_url.get("honk", noise=noise, rid=rid, answer_is_json=False)
695 print(f'30 {lonk_url.build("myhonks")}\r')
698 def authenticated(cert_hash, lonk_url, fn_impl):
699 db_con = db_connect()
700 row = db_con.execute("SELECT client_id, honk_url, token FROM client WHERE cert_hash=?", (cert_hash, )).fetchone()
702 print(f'30 {lonk_url.build("ask_server")}\r')
704 client_id, honk_url, token = row
706 fn_impl(db_con, client_id, lonk_url, HonkUrl(honk_url, token))
709 def proxy(mime, url):
710 with urlopen(url, timeout=10) as response:
711 stdout.buffer.write(b"20 " + mime.encode() + b"\r\n")
713 content = response.read(512 * 1024)
716 stdout.buffer.write(content)
719 def vgi(cert_hash, raw_url):
720 lonk_url = LonkUrl(raw_url)
721 if lonk_url.page == "lonk":
722 authenticated(cert_hash, lonk_url, page_lonk)
723 elif lonk_url.page == "convoy":
724 authenticated(cert_hash, lonk_url, page_convoy)
725 elif lonk_url.page == "atme":
726 authenticated(cert_hash, lonk_url, page_atme)
727 elif lonk_url.page == "search":
728 authenticated(cert_hash, lonk_url, page_search)
729 elif lonk_url.page == "longago":
730 authenticated(cert_hash, lonk_url, page_longago)
731 elif lonk_url.page == "myhonks":
732 authenticated(cert_hash, lonk_url, page_myhonks)
733 elif lonk_url.page == "honker":
734 authenticated(cert_hash, lonk_url, page_honker)
735 elif lonk_url.page == "bonk":
736 authenticated(cert_hash, lonk_url, bonk)
737 elif lonk_url.page == "gethonkers":
738 authenticated(cert_hash, lonk_url, gethonkers)
739 elif lonk_url.page == "addhonker":
740 authenticated(cert_hash, lonk_url, addhonker)
741 elif lonk_url.page == "unsubscribe":
742 authenticated(cert_hash, lonk_url, unsubscribe)
743 elif lonk_url.page == "subscribe":
744 authenticated(cert_hash, lonk_url, subscribe)
745 elif lonk_url.page == "newhonk":
746 authenticated(cert_hash, lonk_url, newhonk)
747 elif lonk_url.page == "honkback":
748 authenticated(cert_hash, lonk_url, honkback)
749 elif lonk_url.page == "ask_server":
750 new_client_stage_1_ask_server(lonk_url)
751 elif lonk_url.page == "ask_username":
752 new_client_stage_2_ask_username(lonk_url)
753 elif lonk_url.page == "ask_password":
754 new_client_stage_3_ask_password(cert_hash, lonk_url)
755 elif lonk_url.page == "proxy":
756 query = {pair[0]: pair[1] for pair in parse_qsl(lonk_url.query)}
757 if "m" not in query or "u" not in query:
758 print("51 Not found\r")
760 proxy(mime=query["m"], url=query["u"])
762 print("51 Not found\r")
765 if __name__ == '__main__':
766 cert_hash_ = environ.get("VGI_CERT_HASH")
769 start_time = clock_gettime(CLOCK_MONOTONIC)
771 input_url = input().strip()
772 vgi(cert_hash_, input_url)
774 stderr.write(f"{cert_hash_}|{input_url}|{clock_gettime(CLOCK_MONOTONIC) - start_time:.3f}sec.\n")
775 except HTTPError as error:
776 stderr.write(f"{error}\n")
777 print(f"43 Remote server return {error.code}: {error.reason}\r")
778 except URLError as error:
779 stderr.write(f"{error}\n")
780 print(f"43 Error while trying to access remote server: {error.reason}\r")
781 except TimeoutError as error:
782 stderr.write(f"{error}\n")
783 print(f"43 Error while trying to access remote server: {error}\r")
785 stderr.write("Certificate required\n")
786 print("60 Certificate required\r")