fix README URLs, handler tests, sync improvements, block/retract gossip

This commit is contained in:
lichenblankie 2026-06-05 00:32:30 +00:00
parent a8aabb3427
commit a2d029097d
4 changed files with 440 additions and 36 deletions

View file

@ -37,6 +37,16 @@ class ForumHandlers:
return False
return secrets.compare_digest(token, expected)
def _author_str(self, name, instance):
if instance == "local":
return "me"
return instance[:6]
def _block_link(self, instance):
if instance == "local":
return ""
return f' <a class="forum-action-inline" href="/forum/blockhash/{instance}">[block]</a>'
def _respond(self, body_html, status=200):
return {
"status": status,
@ -116,6 +126,10 @@ class ForumHandlers:
raw = self.fdb.get_setting("blocked_instances", "")
return set(h.strip() for h in raw.split(",") if h.strip())
def _retracted_threads(self):
t, p = self.fdb.get_retracted_ids()
return t
def _muted_threads(self):
raw = self.fdb.get_setting("muted_threads", "")
return set(h.strip() for h in raw.split(",") if h.strip())
@ -141,16 +155,24 @@ class ForumHandlers:
page = self._paginate(query)
tag = unquote(query.get("tag", [""])[0]).strip()
search = query.get("q", [""])[0].strip()
show_muted = query.get("muted", [""])[0].strip() == "1"
rows, total = self.fdb.get_threads(page=page, per_page=PER_PAGE, tag=tag, search=search)
muted = self._muted_threads()
retracted = self._retracted_threads()
new_count = 0
items = ""
for r in rows:
if r["id"] in muted:
if r["id"] in retracted:
continue
if not self._passes_filters(r):
continue
is_muted = r["id"] in muted
if is_muted and not show_muted:
continue
if self._is_new(r["created_at"]):
new_count += 1
badge = "[share]" if r["url"] else "[request]"
mute_badge = " [muted]" if is_muted else ""
tags_html = ""
if r["tags"]:
tag_links = " ".join(
@ -161,11 +183,11 @@ class ForumHandlers:
reply_label = f"{r['reply_count']} replies" if r['reply_count'] else "no replies"
items += (
f'<li>'
f'<small>{badge}</small> '
f'<small>{badge}{mute_badge}</small> '
f'<a href="/forum/t/{esc(r["id"])}">{esc(r["title"])}</a>'
f'{tags_html}'
f'<br>'
f'<small>{esc(r["author_name"] or r["author_instance"][:8])}'
f'<small>{esc(self._author_str(r["author_name"], r["author_instance"]))}'
f' · {self._time_ago(r["created_at"])}'
f' · {r["score"]} upvotes'
f' · {reply_label}</small>'
@ -180,15 +202,19 @@ class ForumHandlers:
f'</form>'
)
tag_label = f' — tag: {esc(tag)}' if tag else ""
muted_link = f'<a class="forum-action" href="/forum?muted=1">show muted</a>' if not show_muted else f'<a class="forum-action" href="/forum">show all</a>'
page_url = f'/forum?q={esc(search)}&tag={esc(tag)}&muted=1' if show_muted else (f'/forum?q={esc(search)}&tag={esc(tag)}' if search or tag else '/forum')
return self._respond(
f"<h1>forum{tag_label}</h1>"
f"<p>{search_form}"
f' <a href="/forum/new">+ new thread</a>'
f' <a href="/forum/moderation">mod</a>'
f"</p>"
f'<div class="forum-actions">'
f'{search_form}'
f' <a class="forum-action" href="/forum/new">+ new</a>'
f' <a class="forum-action" href="/forum/moderation">mod</a>'
f' {muted_link}'
f"</div>"
f"<p class=\"meta\">{total} threads{new_label}</p>"
f"<ul>{items}</ul>"
f"{self._page_nav(page, total, f'/forum?q={esc(search)}&tag={esc(tag)}' if search or tag else '/forum')}"
f"{self._page_nav(page, total, page_url)}"
)
def handle_new_form(self, msg=""):
@ -222,11 +248,16 @@ class ForumHandlers:
def handle_thread(self, thread_id, query=None):
thread = self.fdb.get_thread(thread_id)
if not thread:
if not thread or not self._passes_filters(thread):
return self._error(404)
posts = self.fdb.get_posts(thread_id)
_, retracted_posts = self.fdb.get_retracted_ids()
posts = [p for p in self.fdb.get_posts(thread_id)
if p["author_instance"] not in self._blocked_instances()
and p["id"] not in retracted_posts]
muted = self._muted_threads()
is_muted = thread["id"] in muted
instance_hash = self.identity.hash.hex() if self.identity else "local"
has_upvoted = self.fdb.has_upvoted(thread_id, instance_hash)
badge = "[share]" if thread["url"] else "[request]"
url_html = ""
@ -246,11 +277,9 @@ class ForumHandlers:
body_html = f"<p>{esc(thread['body'])}</p>" if thread["body"] else ""
mute_btn = (
f'<form method="post" action="/forum/unmute/{thread["id"]}" style="display:inline">'
f'{self._csrf_field()}<button>unmute</button></form>'
f'<a class="forum-action-inline" href="/forum/unmute/{thread["id"]}">unmute</a>'
if is_muted else
f'<form method="post" action="/forum/mute/{thread["id"]}" style="display:inline">'
f'{self._csrf_field()}<button>mute</button></form>'
f'<a class="forum-action-inline" href="/forum/mute/{thread["id"]}">mute</a>'
)
posts_html = ""
@ -267,8 +296,10 @@ class ForumHandlers:
parent_ref = f' <small><a href="#post-{esc(p["parent_id"])}">↪ reply</a></small>'
posts_html += (
f'<div id="post-{esc(p["id"])}" style="margin-bottom:1rem;padding-left:1rem;border-left:2px solid #ddd">'
f'<small><b>{esc(p["author_name"] or p["author_instance"][:8])}</b>'
f' · {self._time_ago(p["created_at"])}{parent_ref}</small>'
f'<small><b>{esc(self._author_str(p["author_name"], p["author_instance"]))}</b>'
f'{self._block_link(p["author_instance"])}'
f' · {self._time_ago(p["created_at"])}{parent_ref}'
f'{" · " + self._post_retract_link(thread["id"], p["id"]) if p["author_instance"] == instance_hash else ""}</small>'
f'<p>{esc(p["body"])}</p>'
f'{save_links}'
f'</div>'
@ -277,7 +308,7 @@ class ForumHandlers:
reply_form = (
f'<form method="post" action="/forum/t/{thread["id"]}/reply">'
f'{self._csrf_field()}'
f'<textarea name="body" rows="4" cols="50" placeholder="share a URL or reply..." required></textarea><br>'
f'<textarea name="body" rows="4" cols="50" placeholder="share a URL or reply..." required></textarea><br><br>'
f'<button type="submit">reply</button>'
f"</form>"
)
@ -285,23 +316,83 @@ class ForumHandlers:
return self._respond(
f"<h1>{badge} {esc(thread['title'])}</h1>"
f'<p class="meta">'
f'by {esc(thread["author_name"] or thread["author_instance"][:8])}'
f'by {esc(self._author_str(thread["author_name"], thread["author_instance"]))}'
f'{self._block_link(thread["author_instance"])}'
f' · {self._time_ago(thread["created_at"])}'
f' · {thread["score"]} upvotes'
f' · {mute_btn}'
f' · <form method="post" action="/forum/t/{thread["id"]}/upvote" style="display:inline">'
f'{self._csrf_field()}<button>+1</button></form>'
f' · <a class="forum-action-inline" href="/forum/t/{thread["id"]}/upvote">{"-1" if has_upvoted else "+1"}</a>'
f'{self._author_links(thread["id"], thread["author_instance"], instance_hash)}'
f'</p>'
f'{url_html}'
f'{body_html}'
f'{tags_html}'
f"<hr>"
f"{posts_html}"
f"<hr>"
f"{reply_form}"
f"{posts_html}<br><br>"
f"{reply_form}<br><br>"
f'<a href="/forum">back to forum</a>'
)
def handle_retract_thread(self, thread_id):
thread = self.fdb.get_thread(thread_id)
if not thread:
return self._error(404)
instance_hash = self.identity.hash.hex() if self.identity else "local"
if thread["author_instance"] != instance_hash:
return self._error(403)
self.fdb.retract_thread(thread_id, instance_hash, self._now())
return self._redirect("/forum")
def handle_retract_post(self, post_id, thread_id):
fdb = self.fdb
posts = fdb.get_posts(thread_id)
post = next((p for p in posts if p["id"] == post_id), None)
if not post:
return self._error(404)
instance_hash = self.identity.hash.hex() if self.identity else "local"
if post["author_instance"] != instance_hash:
return self._error(403)
fdb.retract_post(post_id, instance_hash, self._now())
return self._redirect(f"/forum/t/{thread_id}")
def handle_edit_form(self, thread_id, msg=""):
thread = self.fdb.get_thread(thread_id)
if not thread:
return self._error(404)
instance_hash = self.identity.hash.hex() if self.identity else "local"
if thread["author_instance"] != instance_hash:
return self._error(403)
return self._respond(
f"<h1>edit thread</h1>"
f'<form method="post" action="/forum/t/{thread_id}/edit">'
f'{self._csrf_field()}'
f'<input name="title" value="{esc(thread["title"])}" size="50" required><br><br>'
f'<input name="url" value="{esc(thread["url"] or "")}" placeholder="URL" size="50"><br><br>'
f'<textarea name="body" rows="6" cols="50" placeholder="details or context">{(thread["body"] or "")}</textarea><br><br>'
f'<input name="tags" value="{esc(thread["tags"] or "")}" placeholder="tags, comma-separated" size="50"><br><br>'
f'<button type="submit">save</button>'
f"</form>"
f"<p>{msg}</p>"
f'<a href="/forum/t/{thread_id}">back</a>'
)
def handle_edit_submit(self, thread_id, body):
thread = self.fdb.get_thread(thread_id)
if not thread:
return self._error(404)
instance_hash = self.identity.hash.hex() if self.identity else "local"
if thread["author_instance"] != instance_hash:
return self._error(403)
title = body.get("title", [""])[0].strip()
if not title:
return self.handle_edit_form(thread_id, "Title is required.")
url = body.get("url", [""])[0].strip()
body_text = body.get("body", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
now = self._now()
self.fdb.update_thread(thread_id, title, url, body_text, tags, now)
return self._redirect(f"/forum/t/{thread_id}")
def handle_reply(self, thread_id, body):
body_text = body.get("body", [""])[0].strip()
if not body_text:
@ -334,13 +425,39 @@ class ForumHandlers:
self.fdb.set_setting("muted_threads", ",".join(muted))
return self._redirect(f"/forum/t/{thread_id}")
def _author_links(self, tid, author_instance, instance_hash):
links = ""
if author_instance == instance_hash:
links += f' · <a class="forum-action-inline" href="/forum/t/{tid}/edit">edit</a>'
links += f' · <a class="forum-action-inline" href="/forum/retract/{tid}">retract</a>'
return links
def _post_retract_link(self, tid, pid):
return f'<a class="forum-action-inline" href="/forum/retract/{tid}/post/{pid}">retract</a>'
def _peer_reports_html(self):
counts = self.fdb.get_peer_block_counts()
if not counts:
return "<p>No peer reports yet.</p>"
auto_blocked = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
blocked = self._blocked_instances()
items = ""
for h, count in sorted(counts.items(), key=lambda x: -x[1]):
status = " (blocked)" if h in blocked else " (pending)"
items += f"<li>{esc(h[:16])}... — {count} reports{status}</li>"
return f"<ul>{items}</ul>"
def handle_moderation(self, msg=""):
blocked = self._blocked_instances()
auto_blocked = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
peer_counts = self.fdb.get_peer_block_counts()
blocked_items = ""
if blocked:
for h in sorted(blocked):
label = "[auto] " if h in auto_blocked else ""
reports = f" ({peer_counts.get(h, 0)} peers)" if h in peer_counts else ""
blocked_items += (
f'<li>{esc(h[:16])}... '
f'<li>{label}{esc(h[:16])}...{reports} '
f'<form method="post" action="/forum/unblock" style="display:inline">'
f'{self._csrf_field()}'
f'<input type="hidden" name="instance" value="{esc(h)}">'
@ -374,21 +491,23 @@ class ForumHandlers:
f"{blocked_items}"
f'<form method="post" action="/forum/block">'
f'{self._csrf_field()}'
f'<input name="instance" placeholder="instance hash (32 hex chars)" size="40"> '
f'<input name="instance" placeholder="instance hash (32 hex chars)" size="40"><br><br>'
f'<button>block</button>'
f"</form>"
f"<h2>peer reports</h2>"
f"{self._peer_reports_html()}"
f"<h2>keyword filters</h2>"
f'<form method="post" action="/forum/filters">'
f'{self._csrf_field()}'
f'<input name="keywords" value="{esc(filters_str)}" placeholder="comma-separated keywords" size="50">'
f'<input name="keywords" value="{esc(filters_str)}" placeholder="comma-separated keywords" size="50"><br><br>'
f'<button>save</button>'
f"</form>"
f"<h2>synced instances</h2>"
f"{synced_items}"
f'<form method="post" action="/forum/sync/add">'
f'{self._csrf_field()}'
f'<input name="instance" placeholder="instance hash" size="40"> '
f'<input name="name" placeholder="label (optional)" size="20"> '
f'<input name="instance" placeholder="instance hash" size="40"><br><br>'
f'<input name="name" placeholder="label (optional)" size="20"><br><br>'
f'<button>add</button>'
f"</form>"
f'<hr>'
@ -409,8 +528,26 @@ class ForumHandlers:
blocked = self._blocked_instances()
blocked.discard(instance)
self.fdb.set_setting("blocked_instances", ",".join(blocked))
auto = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
auto.discard(instance)
self.fdb.set_setting("auto_blocked_instances", ",".join(auto))
self.fdb.clear_peer_block(instance)
return self.handle_moderation(f"Unblocked {instance[:16]}...")
def handle_block_hash(self, instance):
if len(instance) == 32 or len(instance) == 64:
blocked = self._blocked_instances()
if instance in blocked:
blocked.discard(instance)
self.fdb.clear_peer_block(instance)
else:
blocked.add(instance)
self.fdb.set_setting("blocked_instances", ",".join(blocked))
auto = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
auto.discard(instance)
self.fdb.set_setting("auto_blocked_instances", ",".join(auto))
return self._redirect("/forum")
def handle_filters(self, body):
keywords = body.get("keywords", [""])[0].strip()
self.fdb.set_setting("keyword_filters", keywords)
@ -438,16 +575,37 @@ class ForumHandlers:
incoming_posts = data.get("posts", [])
incoming_upvotes = data.get("upvotes", [])
blocked = self._blocked_instances()
if incoming_threads:
for t in incoming_threads:
self.fdb.merge_thread(t)
if t.get("author_instance", "") not in blocked:
self.fdb.merge_thread(t)
if incoming_posts:
for p in incoming_posts:
self.fdb.merge_post(p)
if p.get("author_instance", "") not in blocked:
self.fdb.merge_post(p)
if incoming_upvotes:
for uv in incoming_upvotes:
self.fdb.merge_upvote(uv["thread_id"], uv["instance_hash"])
# Record incoming peer blocks
incoming_blocks = data.get("blocks", {})
peer_hash = data.get("peer_hash", "") or data.get("from_hash", "")
if incoming_blocks and peer_hash:
for h in incoming_blocks.get("mine", []):
if h and h not in blocked:
self.fdb.record_peer_block(peer_hash, h)
for h in incoming_blocks.get("peers", []):
if h and h not in blocked:
self.fdb.record_peer_block(peer_hash, h)
# Merge incoming retractions
for r in data.get("retractions", []):
if r.get("id") and r.get("type") and r.get("author") and r.get("at"):
self.fdb.merge_retraction(r["id"], r["type"], r["author"], r["at"])
my_blocks = list(blocked)
my_peer_blocks = self.fdb.get_peer_block_list()
threads, posts, upvote_threads = [], [], []
if since:
ts, posts_list, up_list = self.fdb.get_new_content(since)
@ -455,6 +613,9 @@ class ForumHandlers:
posts = [dict(r) for r in posts_list]
upvote_threads = up_list
retracted = [{"id": cid, "type": ct, "author": ai, "at": ra}
for cid, ct, ai, ra in self.fdb.get_raw_retractions()]
return {
"status": 200,
"content_type": "application/json",
@ -462,6 +623,8 @@ class ForumHandlers:
"threads": threads,
"posts": posts,
"upvote_threads": upvote_threads,
"blocks": {"mine": my_blocks, "peers": my_peer_blocks},
"retractions": retracted,
}),
"headers": {},
}
@ -500,7 +663,23 @@ class ForumHandlers:
return self._with_csrf(self.handle_moderation(), csrf_token)
elif sub.startswith("/t/"):
tid = sub[3:]
if tid.endswith("/upvote"):
return self._with_csrf(self.handle_upvote(tid[:-7], {}), csrf_token)
elif tid.endswith("/edit"):
return self._with_csrf(self.handle_edit_form(tid[:-5]), csrf_token)
return self._with_csrf(self.handle_thread(tid, query), csrf_token)
elif sub.startswith("/retract/"):
rest = sub[9:]
if "/post/" in rest:
tid, pid = rest.split("/post/", 1)
return self._with_csrf(self.handle_retract_post(pid, tid), csrf_token)
return self._with_csrf(self.handle_retract_thread(rest), csrf_token)
elif sub.startswith("/mute/"):
return self._with_csrf(self.handle_mute(sub[6:]), csrf_token)
elif sub.startswith("/unmute/"):
return self._with_csrf(self.handle_unmute(sub[8:]), csrf_token)
elif sub.startswith("/blockhash/"):
return self._with_csrf(self.handle_block_hash(sub[11:]), csrf_token)
elif method == "POST":
if not self._check_csrf(body):
return self._with_csrf(
@ -510,6 +689,9 @@ class ForumHandlers:
return self._with_csrf(self.handle_new_submit(body), csrf_token)
elif sub.startswith("/t/"):
rest = sub[3:]
if rest.endswith("/edit"):
tid = rest[:-5]
return self._with_csrf(self.handle_edit_submit(tid, body), csrf_token)
if "/reply" in rest:
tid = rest.split("/reply")[0]
return self._with_csrf(self.handle_reply(tid, body), csrf_token)