", status)
def _paginate(self, query):
try:
p = int(query.get("p", ["1"])[0])
except (ValueError, IndexError):
p = 1
return max(1, p)
def _page_nav(self, page, total, base_url):
if total <= PER_PAGE:
return ""
total_pages = (total + PER_PAGE - 1) // PER_PAGE
sep = "&" if "?" in base_url else "?"
parts = []
if page > 1:
parts.append(f'« prev')
parts.append(f"page {page} of {total_pages}")
if page < total_pages:
parts.append(f'next »')
return f'
{" | ".join(parts)}
'
def _now(self):
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
def _time_ago(self, ts):
try:
dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S")
except (ValueError, TypeError):
return ts
delta = datetime.now() - dt
if delta.days > 365:
return f"{delta.days // 365}y ago"
if delta.days > 30:
return f"{delta.days // 30}mo ago"
if delta.days > 0:
return f"{delta.days}d ago"
if delta.seconds >= 3600:
return f"{delta.seconds // 3600}h ago"
if delta.seconds >= 60:
return f"{delta.seconds // 60}m ago"
return "just now"
def _is_new(self, ts):
try:
dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S")
except (ValueError, TypeError):
return False
return (datetime.now() - dt).total_seconds() < RECENT_SECONDS
def _blocked_instances(self):
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())
def _keyword_filters(self):
raw = self.fdb.get_setting("keyword_filters", "")
return [k.strip().lower() for k in raw.split(",") if k.strip()]
def _passes_filters(self, thread):
blocked = self._blocked_instances()
if thread["author_instance"] in blocked:
return False
keywords = self._keyword_filters()
if keywords:
text = (thread["title"] + " " + thread["body"]).lower()
if any(k in text for k in keywords):
return False
return True
# --- Routes ---
def handle_list(self, query):
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 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(
f'[{esc(t.strip())}]'
for t in r["tags"].split(",") if t.strip()
)
tags_html = f' {tag_links}'
reply_label = f"{r['reply_count']} replies" if r['reply_count'] else "no replies"
items += (
f'
'
f'{badge}{mute_badge} '
f'{esc(r["title"])}'
f'{tags_html}'
f' '
f'{esc(self._author_str(r["author_name"], r["author_instance"]))}'
f' · {self._time_ago(r["created_at"])}'
f' · {r["score"]} upvotes'
f' · {reply_label}'
f'
'
)
if not items:
items = "
No threads yet.
"
new_label = f" ({new_count} new)" if new_count else ""
search_form = (
f''
)
tag_label = f' — tag: {esc(tag)}' if tag else ""
muted_link = f'show muted' if not show_muted else f'show all'
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"
forum{tag_label}
"
f'
'
f'{search_form}'
f' + new'
f' mod'
f' {muted_link}'
f"
'
)
tags_html = ""
if thread["tags"]:
tag_links = " ".join(
f'[{esc(t.strip())}]'
for t in thread["tags"].split(",") if t.strip()
)
tags_html = f'
{tag_links}
'
body_html = f"
{esc(thread['body'])}
" if thread["body"] else ""
mute_btn = (
f'unmute'
if is_muted else
f'mute'
)
posts_html = ""
for p in posts:
save_links = ""
for word in p["body"].split():
w = word.strip().strip(",.!?;:")
if w.startswith(("http://", "https://")):
save_links += (
f' + save'
)
parent_ref = ""
if p["parent_id"]:
parent_ref = f' ↪ reply'
posts_html += (
f'
'
f'{esc(self._author_str(p["author_name"], p["author_instance"]))}'
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 ""}'
f'
{esc(p["body"])}
'
f'{save_links}'
f'
'
)
reply_form = (
f'"
)
return self._respond(
f"
{badge} {esc(thread['title'])}
"
f'
'
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' · {"-1" if has_upvoted else "+1"}'
f'{self._author_links(thread["id"], thread["author_instance"], instance_hash)}'
f'
"
f'back to forum'
)
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"
edit thread
"
f'"
f"
{msg}
"
f'back'
)
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:
return self._redirect(f"/forum/t/{thread_id}")
parent_id = body.get("parent_id", [""])[0].strip()
author_instance = self.identity.hash.hex() if self.identity else "local"
author_name = self.site_name
post_id = secrets.token_hex(16)
now = self._now()
self.fdb.create_post(post_id, thread_id, parent_id, body_text, author_instance, author_name, now)
return self._redirect(f"/forum/t/{thread_id}")
def handle_upvote(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"
self.fdb.toggle_upvote(thread_id, instance_hash)
return self._redirect(f"/forum/t/{thread_id}")
def handle_mute(self, thread_id):
muted = self._muted_threads()
muted.add(thread_id)
self.fdb.set_setting("muted_threads", ",".join(muted))
return self._redirect(f"/forum")
def handle_unmute(self, thread_id):
muted = self._muted_threads()
muted.discard(thread_id)
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' · edit'
links += f' · retract'
return links
def _post_retract_link(self, tid, pid):
return f'retract'
def _peer_reports_html(self):
counts = self.fdb.get_peer_block_counts()
if not counts:
return "
No peer reports yet.
"
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"
{esc(h[:16])}... — {count} reports{status}
"
return f"
{items}
"
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'
{label}{esc(h[:16])}...{reports} '
f''
f'
'
)
blocked_items = f"
{blocked_items}
"
else:
blocked_items = "
No instances blocked.
"
filters = self._keyword_filters()
filters_str = ", ".join(filters) if filters else ""
synced = self.fdb.get_synced_instances()
synced_items = ""
for s in synced:
synced_items += (
f'
{esc(s["name"] or s["instance_hash"][:16])}... '
f''
f'
Instances are discovered automatically via mesh announces. "
f"You can also manually add a friend's instance hash to bootstrap.
"
f'"
f''
f'back to forum'
)
def handle_block(self, body):
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
if len(instance) != 32:
return self.handle_moderation("Invalid instance hash (must be 32 hex chars).")
blocked = self._blocked_instances()
blocked.add(instance)
self.fdb.set_setting("blocked_instances", ",".join(blocked))
return self.handle_moderation(f"Blocked {instance[:16]}...")
def handle_unblock(self, body):
instance = body.get("instance", [""])[0].strip()
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)
return self.handle_moderation("Filters saved.")
def handle_auto_discover(self, body):
enabled = "1" if body.get("enabled") else "0"
self.fdb.set_setting("forum_auto_discover", enabled)
if self.sync:
self.sync.set_auto_discover(enabled == "1")
return self.handle_moderation(f"Auto-discovery {'enabled' if enabled == '1' else 'disabled'}.")
def handle_storage(self, body):
days = body.get("retention_days", ["30"])[0].strip()
try:
days = max(0, int(days))
except ValueError:
return self.handle_moderation("Invalid retention days.")
self.fdb.set_setting("forum_retention_days", str(days))
return self.handle_moderation(f"Storage retention set to {days} days.")
def handle_sync_add(self, body):
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
name = body.get("name", [""])[0].strip()
if len(instance) != 32:
return self.handle_moderation("Invalid instance hash (must be 32 hex chars).")
self.fdb.upsert_synced_instance(instance, name)
return self.handle_moderation(f"Added {name or instance[:16]}... to sync.")
def handle_unsync(self, body):
instance = body.get("instance", [""])[0].strip()
self.fdb.remove_synced_instance(instance)
return self.handle_moderation("Removed.")
# --- Sync endpoint (called over RNS) ---
def handle_sync_request(self, data):
"""Handle incoming sync request from another forum instance."""
since = data.get("query", {}).get("since", [""])[0] if isinstance(data.get("query"), dict) else ""
incoming_threads = data.get("threads", [])
incoming_posts = data.get("posts", [])
incoming_upvotes = data.get("upvotes", [])
blocked = self._blocked_instances()
if incoming_threads:
for t in incoming_threads:
if t.get("author_instance", "") not in blocked:
self.fdb.merge_thread(t)
if incoming_posts:
for p in incoming_posts:
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"])
# Auto-discover the peer that synced with us and their known peers
from_hash = data.get("from_hash", "")
if from_hash and from_hash not in blocked:
self.fdb.add_known_peer(from_hash)
for peer_hash in data.get("known_peers", []):
if peer_hash and peer_hash != from_hash and peer_hash not in blocked:
self.fdb.add_known_peer(peer_hash)
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)
threads = [dict(r) for r in ts]
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()]
known_peers = [h for h in self.fdb.get_all_known_hashes() if h != from_hash]
return {
"status": 200,
"content_type": "application/json",
"body": json.dumps({
"threads": threads,
"posts": posts,
"upvote_threads": upvote_threads,
"blocks": {"mine": my_blocks, "peers": my_peer_blocks},
"retractions": retracted,
"known_peers": known_peers,
}),
"headers": {},
}
def handle_sync_add_instance(self, body):
"""Add instance for sync (from moderation page action)."""
return self.handle_sync_add(body)
# --- Router ---
def _with_csrf(self, resp, csrf_token):
resp.setdefault("headers", {})
if resp.get("content_type", "").startswith("text/html"):
resp["headers"]["Set-Cookie"] = (
f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/forum"
)
return resp
def handle(self, method, path, query, body, cookies=None):
csrf_token = (cookies or {}).get("_csrf", "")
if not csrf_token:
csrf_token = secrets.token_hex(32)
self._request_local.csrf_token = csrf_token
if not path.startswith("/forum"):
return self._with_csrf(self._error(404), csrf_token)
sub = path[len("/forum"):]
if method == "GET":
if sub == "" or sub == "/":
return self._with_csrf(self.handle_list(query), csrf_token)
elif sub == "/new":
return self._with_csrf(self.handle_new_form(), csrf_token)
elif sub == "/moderation":
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(
self._respond("
403 Forbidden
", status=403), csrf_token
)
if sub == "/new":
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)
elif rest.endswith("/upvote"):
tid = rest[:-7]
return self._with_csrf(self.handle_upvote(tid, body), 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 == "/block":
return self._with_csrf(self.handle_block(body), csrf_token)
elif sub == "/unblock":
return self._with_csrf(self.handle_unblock(body), csrf_token)
elif sub == "/filters":
return self._with_csrf(self.handle_filters(body), csrf_token)
elif sub == "/sync/add":
return self._with_csrf(self.handle_sync_add(body), csrf_token)
elif sub == "/unsync":
return self._with_csrf(self.handle_unsync(body), csrf_token)
elif sub == "/auto_discover":
return self._with_csrf(self.handle_auto_discover(body), csrf_token)
elif sub == "/storage":
return self._with_csrf(self.handle_storage(body), csrf_token)
return self._with_csrf(self._error(404), csrf_token)
def handle_sync(self, data):
"""Entry point for incoming RNS sync requests."""
return self.handle_sync_request(data)