Last active
March 2, 2026 22:13
-
-
Save saagarjha/f019c94ab4ed2f2e5f8108ee88dd3355 to your computer and use it in GitHub Desktop.
Simple read-only HTML frontend for Pleroma
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import datetime | |
| import html | |
| import html.parser | |
| import http.server | |
| import json | |
| import sys | |
| import urllib.request | |
| import urllib.parse | |
| PLEROMA = sys.argv[1] | |
| PORT = int(sys.argv[2]) | |
| def api(path): | |
| return json.loads(urllib.request.urlopen(PLEROMA + path).read()) | |
| class HTMLExtractor(html.parser.HTMLParser): | |
| def __init__(self): | |
| super().__init__() | |
| self.text = "" | |
| def handle_data(self, data): | |
| self.text += data | |
| def html_extract(html): | |
| extractor = HTMLExtractor() | |
| extractor.feed(html) | |
| return extractor.text | |
| def html_escape(text): | |
| return html.escape(text).replace("\n", " ").replace("\r", " ") | |
| class Context: | |
| def __init__(self, status): | |
| context = api(f"/api/v1/statuses/{status['id']}/context") | |
| self._statuses = {} | |
| self._parent = {} | |
| self._children = {} | |
| self._children = {} | |
| for ancestor in context["ancestors"]: | |
| self._add_status(ancestor) | |
| self._add_status(status) | |
| for descendant in context["descendants"]: | |
| self._add_status(descendant) | |
| def _add_status(self, status): | |
| self._statuses[status["id"]] = status | |
| self._children[status["id"]] = [] | |
| if status["in_reply_to_id"] is not None: | |
| self._parent[status["id"]] = status["in_reply_to_id"] | |
| self._children[status["in_reply_to_id"]].append(status["id"]) | |
| def children(self, status): | |
| return [self._statuses[child] for child in self._children[status["id"]]] | |
| def parent(self, status): | |
| parent = self._parent.get(status["id"]) | |
| return parent and self._statuses[parent] | |
| def format_date(date): | |
| date = datetime.datetime.fromisoformat(date) | |
| age = datetime.datetime.now(datetime.timezone.utc) - date | |
| if age.total_seconds() < 60 * 60: | |
| relative = f"{int(age.total_seconds() / 60)}m ago" | |
| elif age.total_seconds() < 60 * 60 * 24: | |
| relative = f"{int(age.total_seconds() / 60 / 60)}h ago" | |
| else: | |
| relative = date.strftime("%m/%d/%Y") | |
| return f"""<time title="{date.isoformat()}" datetime="{date.isoformat()}">{relative}</time>""" | |
| def format_attachment(attachment): | |
| alt = ( | |
| f"""alt="{html_escape(attachment["description"])}" """ | |
| if attachment["description"] | |
| else "" | |
| ) | |
| title = ( | |
| f"""title="{html_escape(attachment["description"])}" """ | |
| if attachment["description"] | |
| else "" | |
| ) | |
| if attachment["type"] == "image": | |
| return f"""<img src="{attachment["url"]}" {alt} {title} />""" | |
| elif attachment["type"] == "video": | |
| return f"""<video src="{attachment["url"]}" controls {title}></video>""" | |
| elif attachment["type"] == "audio": | |
| return f"""<audio src="{attachment["url"]}" controls {title}></audio>""" | |
| else: | |
| return "" | |
| # NOTE: This is Pleroma-specific | |
| def internal_url(status): | |
| return f"/notice/{status['id']}" | |
| def format_status(status, context=None, level=0): | |
| context = Context(status) if context is None else context | |
| children = context.children(status) | |
| offspring = sorted( | |
| [ | |
| child | |
| for child in children | |
| if status["account"]["id"] == child["account"]["id"] | |
| ], | |
| key=lambda child: child["favourites_count"], | |
| reverse=True, | |
| ) | |
| if offspring: | |
| heir = offspring[0] | |
| elif len(children) == 1: | |
| heir = children[0] | |
| else: | |
| heir = None | |
| if heir is not None: | |
| children.remove(heir) | |
| parent = context.parent(status) | |
| parent = ( | |
| f"""<a href="{internal_url(parent)}">(replying to {html_escape(parent["account"]["display_name"])})</a>""" | |
| if parent is not None | |
| else "" | |
| ) | |
| heir = format_status(heir, context, level) if heir else "" | |
| children = [format_status(child, context, level + 1) for child in children] | |
| if len(children): | |
| children = f"""\ | |
| <div class="status-replies status-reply-level-{level % 2}"> | |
| <a class="status-replies-collapsed" href="{internal_url(status)}">{len(children)} replies →</a> | |
| <details class="status-replies-expanded"> | |
| <summary>{len(children)} replies</summary> | |
| <div class="status-children"> | |
| {"<hr>".join(children)} | |
| </div> | |
| </details> | |
| </div>\ | |
| """ | |
| else: | |
| children = "" | |
| account = status["account"] | |
| attachments = [ | |
| format_attachment(attachment) for attachment in status["media_attachments"] | |
| ] | |
| return f"""\ | |
| <div class="status"> | |
| <a href="{account["url"]}"><img class="status-profile" src="{account["avatar"]}" /></a> | |
| <div class="status-container {"status-continued" if heir else ""}"> | |
| <hgroup class="status-profile-header"> | |
| <h3>{html_escape(account["display_name"])}</h3> | |
| <h5>{format_date(status["created_at"])} {parent}</h5> | |
| </hgroup> | |
| <div class="status-content"> | |
| {status["content"]} | |
| </div> | |
| <div class="status-attachments"> | |
| {"\n".join(attachments)} | |
| </div> | |
| <footer><a href="">{status["favourites_count"]} Likes</a> • <a href="">{status["reblogs_count"]} Reposts</a> • <a href="{status["url"]}">Link</a></footer> | |
| {children} | |
| </div> | |
| </div> | |
| {heir}\ | |
| """ | |
| def format_page(body, head=""): | |
| return f"""\ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Saagar Jha (@saagar@saagarjha.com)</title> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| {head} | |
| <style> | |
| root {{ | |
| color-scheme: light dark; | |
| }} | |
| body {{ | |
| background-color: #FFF; | |
| font-family: system-ui, Helvetica, sans-serif; | |
| margin: 0; | |
| }} | |
| main {{ | |
| color: black; | |
| margin: 0rem auto; | |
| max-width: 40rem; | |
| word-wrap: break-word; | |
| }} | |
| main * {{ | |
| box-sizing: border-box; | |
| max-width: 100%; | |
| }} | |
| main > * {{ | |
| padding: 0 1rem; | |
| }} | |
| main > header {{ | |
| padding: 0; | |
| }} | |
| a {{ | |
| color: #3D84FF; | |
| text-decoration: none; | |
| }} | |
| a:hover {{ | |
| text-decoration: underline; | |
| }} | |
| table {{ | |
| border-collapse: collapse; | |
| }} | |
| th, td {{ | |
| border: 1px solid #CCC; | |
| padding: 0.375rem; | |
| }} | |
| hr {{ | |
| margin: 1rem 0; | |
| }} | |
| footer {{ | |
| font-size: smaller; | |
| }} | |
| .header-content {{ | |
| margin: 1rem; | |
| }} | |
| .profile {{ | |
| align-self: flex-start; | |
| border-radius: 1rem; | |
| width: 5rem; | |
| }} | |
| .profile-card {{ | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 1rem; | |
| }} | |
| .profile-header {{ | |
| flex: 1; | |
| }} | |
| .profile-header * {{ | |
| margin-block: 0.5rem; | |
| }} | |
| .status {{ | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 1rem; | |
| margin: 1rem 0; | |
| }} | |
| .status-profile {{ | |
| border-radius: 0.5rem; | |
| width: 3rem; | |
| }} | |
| .status-profile-header {{ | |
| flex: 1; | |
| }} | |
| .status-profile-header * {{ | |
| margin-block: 0.25rem; | |
| }} | |
| .status-container {{ | |
| container: status / inline-size; | |
| flex: 1; | |
| min-width: 0; | |
| position: relative; | |
| }} | |
| .status-container.status-continued:before {{ | |
| background: #AAA; | |
| content: ""; | |
| left: -2.5rem; | |
| position: absolute; | |
| top: 4rem; | |
| bottom: 0; | |
| width: 1px; | |
| }} | |
| .status-content {{ | |
| margin: 1rem 0; | |
| }} | |
| .status-replies {{ | |
| border-radius: 1rem; | |
| margin: 1rem -1rem; | |
| padding: 0 1rem; | |
| }} | |
| .status-replies > * {{ | |
| margin: 1rem 0; | |
| }} | |
| .status-replies-expanded {{ | |
| width: 100%; | |
| }} | |
| @container status (max-width: 10rem) {{ | |
| .status-replies-collapsed {{ | |
| display: inline-block; | |
| }} | |
| .status-replies-expanded {{ | |
| display: none; | |
| }} | |
| }} | |
| @container status (min-width: 10rem) {{ | |
| .status-replies-collapsed {{ | |
| display: none; | |
| }} | |
| .status-replies-expanded {{ | |
| display: inline-block; | |
| }} | |
| }} | |
| .status-reply-level-0 {{ | |
| background-color: #00000008; | |
| }} | |
| .status-reply-level-1 {{ | |
| background-color: #FFF; | |
| }} | |
| @media(prefers-color-scheme: dark) {{ | |
| body {{ | |
| background-color: #222; | |
| }} | |
| main {{ | |
| color: white; | |
| }} | |
| a {{ | |
| color: #44B4FF; | |
| }} | |
| .status-container:before {{ | |
| background: #EEE; | |
| }} | |
| .status-reply-level-0 {{ | |
| background-color: #FFFFFF08; | |
| }} | |
| .status-reply-level-1 {{ | |
| background-color: #222; | |
| }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <main> | |
| {body} | |
| </main> | |
| </body> | |
| </html> | |
| """ | |
| def root(): | |
| return format_page("""\ | |
| <h1>Hello!</h1> | |
| <p>This is the <a href="https://en.wikipedia.org/wiki/ActivityPub">ActivityPub</a> presence of <a href="https://saagarjha.com">Saagar Jha</a>.</p> \ | |
| """) | |
| def profile(account): | |
| account = api(f"/api/v1/accounts/{account}") | |
| fields = [ | |
| f"<tr><td>{field['name']}</td><td>{field['value']}</td></tr>" | |
| for field in account["fields"] | |
| ] | |
| statuses = [ | |
| format_status(status) | |
| for status in api(f"/api/v1/accounts/{account['id']}/statuses") | |
| ] | |
| return format_page( | |
| f""" | |
| <header> | |
| <img class="header" src="{account["header"]}" /> | |
| <section class="header-content"> | |
| <div class="profile-card"> | |
| <a href="{account["url"]}"><img class="profile" src="{account["avatar"]}" /></a> | |
| <hgroup class="profile-header"> | |
| <h1>{html_escape(account["display_name"])}</h1> | |
| <h2><a href="{account["url"]}">@{html_escape(account["fqn"])}</a></h2> | |
| </hgroup> | |
| </div> | |
| {account["note"]} | |
| <p>Joined {format_date(account["created_at"])} • {account["following_count"]} following • {account["followers_count"]} followers</p> | |
| <div> | |
| <table> | |
| {"\n".join(fields)} | |
| </table> | |
| </div> | |
| </section> | |
| </header> | |
| <hr> | |
| {"<hr>".join(statuses)}\ | |
| """, | |
| f"""\ | |
| <meta property="og:title" content="{html_escape(account["display_name"])}"> | |
| <meta property="og:description" content="{html_extract(account["note"])}"> | |
| <meta property="og:image" content="{account["avatar"]}"> | |
| <meta property="og:url" content="{account["url"]}"> | |
| <meta property="og:type" content="profile"> | |
| <link rel="canonical" href="{account["url"]}"> | |
| <link rel="alternate" type="application/activity+json" href="{account["url"]}">\ | |
| """, | |
| ) | |
| def notice(id): | |
| status = api(f"/api/v1/statuses/{id}") | |
| if len(status["media_attachments"]) and status["media_attachments"][0]["type"] in [ | |
| "image", | |
| "video", | |
| "audio", | |
| ]: | |
| attachment = status["media_attachments"][0] | |
| media = f"""<meta property="og:{attachment["type"]}" content="{attachment["url"]}">""" | |
| if attachment["type"] == "image": | |
| alt = f"""<meta property="og:image:alt" content="{html_escape(attachment["description"])}">""" | |
| else: | |
| alt = "" | |
| else: | |
| media = ( | |
| f"""<meta property="og:image" content="{status["account"]["avatar"]}">""" | |
| ) | |
| alt = "" | |
| return format_page( | |
| format_status(status), | |
| f"""\ | |
| <meta property="og:title" content="{html_escape(status["account"]["display_name"])}"> | |
| <meta property="og:description" content="{html_extract(status["content"])}"> | |
| {media} | |
| {alt} | |
| <meta property="og:url" content="{status["url"]}"> | |
| <meta property="article:published_time" content="{status["created_at"]}"> | |
| <meta property="og:type" content="article"> | |
| <link rel="canonical" href="{status["url"]}"> | |
| <link rel="alternate" type="application/activity+json" href="{status["uri"]}">\ | |
| """, | |
| ) | |
| class Handler(http.server.BaseHTTPRequestHandler): | |
| def do_GET(self): | |
| path = urllib.parse.urlparse(self.path).path | |
| if path == "/": | |
| self.serve(root()) | |
| elif path.startswith("/users/"): | |
| self.serve(profile(path.removeprefix("/users/"))) | |
| elif path.startswith("/notice/"): | |
| self.serve(notice(path.removeprefix("/notice/"))) | |
| else: | |
| assert False | |
| def serve(self, html): | |
| body = html.encode() | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.end_headers() | |
| self.wfile.write(body) | |
| if __name__ == "__main__": | |
| http.server.HTTPServer(("", PORT), Handler).serve_forever() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment