import json import secrets import threading from datetime import datetime from urllib.parse import unquote PER_PAGE = 20 RECENT_SECONDS = 86400 * 7 # "new" = within last 7 days def esc(s): import html return html.escape(str(s)) 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() 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 _respond(self, body_html, status=200): return { "status": status, "content_type": "text/html; charset=utf-8", "body": body_html, "headers": {}, } 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"

{status}

", 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 _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() rows, total = self.fdb.get_threads(page=page, per_page=PER_PAGE, tag=tag, search=search) muted = self._muted_threads() new_count = 0 items = "" for r in rows: if r["id"] in muted: continue if self._is_new(r["created_at"]): new_count += 1 badge = "[share]" if r["url"] else "[request]" 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} ' f'{esc(r["title"])}' f'{tags_html}' f'
    ' f'{esc(r["author_name"] or r["author_instance"][:8])}' 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'
    ' f'' f'
    ' ) tag_label = f' — tag: {esc(tag)}' if tag else "" return self._respond( f"

    forum{tag_label}

    " f"

    {search_form}" f' + new thread' f' mod' f"

    " f"

    {total} threads{new_label}

    " f"" f"{self._page_nav(page, total, f'/forum?q={esc(search)}&tag={esc(tag)}' if search or tag else '/forum')}" ) def handle_new_form(self, msg=""): return self._respond( f"

    new thread

    " f'
    ' f'{self._csrf_field()}' f'

    ' f'

    ' f'

    ' f'

    ' f'' 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.") 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: return self._error(404) posts = self.fdb.get_posts(thread_id) muted = self._muted_threads() is_muted = thread["id"] in muted badge = "[share]" if thread["url"] else "[request]" url_html = "" if thread["url"]: url_html = ( f'

    {esc(thread["url"])}' f' (+ save to my index)

    ' ) 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'
    ' f'{self._csrf_field()}
    ' if is_muted else f'
    ' f'{self._csrf_field()}
    ' ) 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(p["author_name"] or p["author_instance"][:8])}' f' · {self._time_ago(p["created_at"])}{parent_ref}' f'

    {esc(p["body"])}

    ' f'{save_links}' f'
    ' ) reply_form = ( f'
    ' f'{self._csrf_field()}' f'
    ' f'' f"
    " ) return self._respond( f"

    {badge} {esc(thread['title'])}

    " f'

    ' f'by {esc(thread["author_name"] or thread["author_instance"][:8])}' f' · {self._time_ago(thread["created_at"])}' f' · {thread["score"]} upvotes' f' · {mute_btn}' f' ·

    ' f'{self._csrf_field()}
    ' f'

    ' f'{url_html}' f'{body_html}' f'{tags_html}' f"
    " f"{posts_html}" f"
    " f"{reply_form}" f'back to forum' ) 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 handle_moderation(self, msg=""): blocked = self._blocked_instances() blocked_items = "" if blocked: for h in sorted(blocked): blocked_items += ( f'
  • {esc(h[:16])}... ' f'
    ' f'{self._csrf_field()}' f'' f'
    ' f'
  • ' ) blocked_items = f"" 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'{self._csrf_field()}' f'' f'
    ' f'
  • ' ) synced_items = f"" if synced_items else "

    No instances synced yet.

    " return self._respond( f"

    forum moderation

    " f"

    {msg}

    " f"

    blocked instances

    " f"{blocked_items}" f'
    ' f'{self._csrf_field()}' f' ' f'' f"
    " f"

    keyword filters

    " f'
    ' f'{self._csrf_field()}' f'' f'' f"
    " f"

    synced instances

    " f"{synced_items}" f'
    ' f'{self._csrf_field()}' f' ' f' ' f'' 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)) return self.handle_moderation(f"Unblocked {instance[:16]}...") 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_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", []) if incoming_threads: for t in incoming_threads: self.fdb.merge_thread(t) if incoming_posts: for p in incoming_posts: self.fdb.merge_post(p) if incoming_upvotes: for uv in incoming_upvotes: self.fdb.merge_upvote(uv["thread_id"], uv["instance_hash"]) 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 return { "status": 200, "content_type": "application/json", "body": json.dumps({ "threads": threads, "posts": posts, "upvote_threads": upvote_threads, }), "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:] return self._with_csrf(self.handle_thread(tid, query), 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 "/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) 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)