import json import secrets import threading from datetime import datetime from urllib.parse import unquote MAX_TITLE_LENGTH = 200 MAX_BODY_LENGTH = 10000 PER_PAGE = 20 RECENT_SECONDS = 86400 * 7 # "new" = within last 7 days def esc(s): import html return html.escape(str(s)) from tinyweb_forum.bloom import BloomFilter class ForumHandlers: def __init__(self, fdb, sync, identity, reticulum, site_name="me"): self.fdb = fdb self.sync = sync self.identity = identity self.reticulum = reticulum self.site_name = site_name self._request_local = threading.local() self._flash = {} def _get_csrf(self): return getattr(self._request_local, 'csrf_token', '') def _csrf_field(self): token = self._get_csrf() return f'' def _check_csrf(self, body): token = body.get("_csrf", [""])[0] expected = self._get_csrf() if not expected or not token: return False return secrets.compare_digest(token, expected) def _set_flash(self, msg): self._flash[self._get_csrf()] = msg def _get_flash(self): return self._flash.pop(self._get_csrf(), "") def _is_local(self, instance): if instance == "local": return True if self.identity and instance == self.identity.hash.hex(): return True return False def _author_str(self, name, instance): if self._is_local(instance): return "me" return instance[:6] def _block_link(self, instance): if self._is_local(instance): return "" return f' block' def _respond(self, body_html, status=200): return { "status": status, "content_type": "text/html; charset=utf-8", "body": body_html, "headers": {}, } def _redirect(self, location): return { "status": 302, "content_type": "text/html; charset=utf-8", "body": "", "headers": {"Location": location}, } def _json(self, data, status=200): return { "status": status, "content_type": "application/json", "body": json.dumps(data), "headers": {}, } def _redirect(self, location): return { "status": 302, "content_type": "text/html; charset=utf-8", "body": "", "headers": {"Location": location}, } def _error(self, status): return self._respond(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 _get_subscribed_topics(self): raw = self.fdb.get_setting("topic_subscriptions", "") return [t.strip().lower() for t in raw.split(",") if t.strip()] def _get_subscribed_topics_str(self): raw = self.fdb.get_setting("topic_subscriptions", "") return raw 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 _status_bar(self): topics = self._get_subscribed_topics() topics_str = ", ".join(topics) if topics else "everything" peer_count = len(self.fdb.get_synced_instances()) return ( f'no threads yet.
" new_label = f" ({new_count} new)" if new_count else "" search_form = ( f'' ) tag_label = f' — {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"" f'+ new thread' f'' f' · mod' f' · sync now' f' · {muted_link}' f"" f"
" f'Share a URL or start a discussion.
" f'" f"{msg}
" f'back' ) def handle_new_submit(self, body): title = body.get("title", [""])[0].strip() url = body.get("url", [""])[0].strip() body_text = body.get("body", [""])[0].strip() tags = body.get("tags", [""])[0].strip() if not title: return self.handle_new_form("Title is required.") if len(title) > MAX_TITLE_LENGTH: return self.handle_new_form(f"Title too long (max {MAX_TITLE_LENGTH} characters).") if len(body_text) > MAX_BODY_LENGTH: return self.handle_new_form(f"Body too long (max {MAX_BODY_LENGTH} characters).") thread_id = secrets.token_hex(16) author_instance = self.identity.hash.hex() if self.identity else "local" author_name = self.site_name now = self._now() self.fdb.create_thread(thread_id, title, url, body_text, tags, author_instance, author_name, now) return self._redirect(f"/forum/t/{thread_id}") def handle_thread(self, thread_id, query=None): thread = self.fdb.get_thread(thread_id) if not thread or not self._passes_filters(thread): return self._error(404) _, 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 = "" if thread["url"]: url_html = ( f'{esc(thread["url"])}' f' (+ save)
' ) 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_label = "unmute" if is_muted else "mute" mute_href = f'/forum/unmute/{thread["id"]}' if is_muted else f'/forum/mute/{thread["id"]}' 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'{esc(p["body"])}
' f'{save_links}' f'Update your 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.") if len(title) > MAX_TITLE_LENGTH: return self.handle_edit_form(thread_id, f"Title too long (max {MAX_TITLE_LENGTH} characters).") url = body.get("url", [""])[0].strip() body_text = body.get("body", [""])[0].strip() if len(body_text) > MAX_BODY_LENGTH: return self.handle_edit_form(thread_id, f"Body too long (max {MAX_BODY_LENGTH} characters).") 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}") if len(body_text) > MAX_BODY_LENGTH: return self._respond(f"Body too long (max {MAX_BODY_LENGTH} characters). back
") 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.append(f"{esc(h[:16])}... — {count} reports{status}") return '' + "
".join(items) + "
| this instance | {esc(local_hash[:16])}... |
| threads | {total} |
| posts | {total_posts} |
| known peers | {len(synced)} |
| auto-discover | {'on' if self.fdb.get_setting('forum_auto_discover', '1') == '1' else 'off'} |
| auto-sync | {'on' if self.fdb.get_setting('forum_auto_sync', '0') == '1' else 'off'} |
| retention | {self.fdb.get_setting('forum_retention_days', '30')} days |
| topic filter | {esc(self.fdb.get_setting('topic_subscriptions', '') or '(none)')} |
no peers known yet
" else: html += "| hash | name | threads | last sync |
|---|---|---|---|
| {h} | {name} | {count} | {last} |
| {esc(h[:16])}... |
no instances blocked
" synced_items = "" for s in synced: synced_items += ( f'no instances synced
" msg_html = f'{esc(msg)}
' if msg else "" return self._respond( f"network behavior
" f'" f"{len(synced)} known peers
" f'{synced_items}' f'" f"Sync not available.
") count = self.sync.sync_now() msg = f"Synced with {count} instance{'s' if count != 1 else ''}." if count == 0: msg = "No peers to sync with." return self._respond(f"{msg}
") def handle_sync_add(self, body): instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "") name = body.get("name", [""])[0].strip() if len(instance) != 32: self._set_flash("Invalid instance hash (must be 32 hex chars).") return self._redirect("/forum/moderation") self.fdb.upsert_synced_instance(instance, name) self._set_flash(f"Added {name or instance[:16]}... to sync.") return self._redirect("/forum/moderation") def handle_unsync(self, body): instance = body.get("instance", [""])[0].strip() self.fdb.remove_synced_instance(instance) self._set_flash("Removed.") return self._redirect("/forum/moderation") def handle_topics(self, body): topics = body.get("topics", [""])[0].strip() self.fdb.set_setting("topic_subscriptions", topics) self._set_flash("Topic subscriptions saved.") return self._redirect("/forum/moderation") # --- Sync endpoint (called over RNS) --- def handle_sync_request(self, data): 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", []) peer_bloom_data = data.get("content_bloom") peer_tag_bloom_data = data.get("tag_bloom") blocked = self._blocked_instances() my_topics = self._get_subscribed_topics() from_hash = data.get("from_hash", "") # Merge trust gossip from requester if from_hash: self.sync._merge_trust_gossip(data.get("trust_list", []), from_hash) # Build our content bloom filter for dedup our_existing = set() peer_topics = data.get("my_topics", []) for t in self.fdb.get_threads_by_topics(peer_topics) if peer_topics else []: our_existing.add(t["id"]) our_bloom = BloomFilter.from_items(list(our_existing), data.get("bloom_size", 2048) if data else 2048, data.get("bloom_hashes", 3) if data else 3) # Parse peer's content bloom for dedup peer_bloom = None if peer_bloom_data: peer_bloom = BloomFilter.from_bytes( bytes(peer_bloom_data), data.get("bloom_size", 2048), data.get("bloom_hashes", 3), ) # Merge incoming content, filtered by bloom and topics if incoming_threads: for t in incoming_threads: author = t.get("author_instance", "") if author in blocked: continue if author and not self.fdb.is_trusted(author): continue if peer_bloom and peer_bloom.might_contain(t["id"]): continue if not my_topics: self.fdb.merge_thread(t) else: t_tags = [tag.strip().lower() for tag in t.get("tags", "").split(",") if tag.strip()] if set(my_topics) & set(t_tags): self.fdb.merge_thread(t) if incoming_posts: for p in incoming_posts: author = p.get("author_instance", "") if author in blocked: continue if author and not self.fdb.is_trusted(author): continue if peer_bloom and peer_bloom.might_contain(p["id"]): continue self.fdb.merge_post(p) if incoming_upvotes: for uv in incoming_upvotes: self.fdb.merge_upvote(uv["thread_id"], uv["instance_hash"]) incoming_blocks = data.get("blocks", {}) peer_hash = data.get("peer_hash", "") or 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) 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"]) 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: if peer_topics: rows = self.fdb.get_threads_by_topics(peer_topics, since=since) threads = [dict(r) for r in rows] tids = [r["id"] for r in rows] posts = [dict(p) for p in self.fdb.get_posts_by_thread_ids(tids)] uv_rows = self.fdb.get_new_upvotes_since(since, tids) upvote_threads = uv_rows else: 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 # Filter outgoing content by requester's trust list requester_trust = set(data.get("trust_list", [])) if requester_trust: threads = [t for t in threads if t.get("author_instance", "") in requester_trust] thread_ids = set(t["id"] for t in threads) posts = [p for p in posts if p.get("thread_id") in thread_ids] known_peers = [h for h in self.fdb.get_all_known_hashes() if h != from_hash] 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", "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, "content_bloom": list(our_bloom.bytes), "bloom_size": data.get("bloom_size", 2048), "bloom_hashes": data.get("bloom_hashes", 3), "trust_list": self.fdb.get_all_trusted_hashes(), }), "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(query), csrf_token) elif sub == "/status": return self._with_csrf(self.handle_status(), 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 sub == "/sync/now": return self._with_csrf(self.handle_sync_now(), csrf_token) elif method == "POST": if not self._check_csrf(body): return self._with_csrf( self._respond("