Skip to content

Instantly share code, notes, and snippets.

@saagarjha
Last active March 2, 2026 22:13
Show Gist options
  • Select an option

  • Save saagarjha/f019c94ab4ed2f2e5f8108ee88dd3355 to your computer and use it in GitHub Desktop.

Select an option

Save saagarjha/f019c94ab4ed2f2e5f8108ee88dd3355 to your computer and use it in GitHub Desktop.
Simple read-only HTML frontend for Pleroma
#!/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