From a89edde2de6f2229e00e4d39b4fe2961398a9c8e Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 4 Jun 2026 08:23:51 +0000
Subject: [PATCH 01/58] initial: decentralized link-sharing forum for TinyWeb
---
.gitignore | 5 +
README.md | 25 ++
pyproject.toml | 19 ++
tinyweb_forum/__init__.py | 34 +++
tinyweb_forum/db.py | 321 +++++++++++++++++++++++
tinyweb_forum/handlers.py | 538 ++++++++++++++++++++++++++++++++++++++
tinyweb_forum/sync.py | 146 +++++++++++
7 files changed, 1088 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 pyproject.toml
create mode 100644 tinyweb_forum/__init__.py
create mode 100644 tinyweb_forum/db.py
create mode 100644 tinyweb_forum/handlers.py
create mode 100644 tinyweb_forum/sync.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a376320
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.pyc
+*.pyo
+dist/
+*.egg-info/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4508429
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# tinyweb-forum
+
+A decentralized link-sharing forum for [TinyWeb](https://github.com/derickfay/tinyweb). Share URLs and discuss them with other TinyWeb instances over the Reticulum mesh.
+
+## Install
+
+```bash
+pip install tinyweb-forum
+```
+
+Enable the forum in TinyWeb's customize page (`/style`).
+
+## Development
+
+```bash
+git clone https://github.com/derickfay/tinyweb-forum
+pip install -e .
+```
+
+## How it works
+
+- Each TinyWeb instance stores forum threads and posts in its own `forum.db`
+- Instances sync content with each other over RNS
+- Moderation is per-instance: block instances, mute threads, keyword filters
+- No global server, no algorithms, no tracking
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..19a0bb7
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,19 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.backends._legacy:_Backend"
+
+[project]
+name = "tinyweb-forum"
+version = "0.1.0"
+description = "Decentralized link-sharing forum for TinyWeb"
+license = {text = "MIT"}
+requires-python = ">=3.9"
+dependencies = [
+ "rns",
+]
+
+[project.optional-dependencies]
+dev = []
+
+[tool.setuptools.packages.find]
+include = ["tinyweb_forum*"]
diff --git a/tinyweb_forum/__init__.py b/tinyweb_forum/__init__.py
new file mode 100644
index 0000000..ffdbc9c
--- /dev/null
+++ b/tinyweb_forum/__init__.py
@@ -0,0 +1,34 @@
+from tinyweb_forum.db import ForumDB
+from tinyweb_forum.handlers import ForumHandlers
+from tinyweb_forum.sync import ForumSync
+
+FORUM_ENABLED_KEY = "forum_enabled"
+
+
+class ForumPlugin:
+ def __init__(self, data_dir, identity, reticulum, site_name="me"):
+ self.fdb = ForumDB(data_dir)
+ self.handlers = ForumHandlers(
+ self.fdb, None, identity, reticulum, site_name=site_name
+ )
+ self.sync = ForumSync(self.fdb, identity, reticulum, lambda: self.handlers)
+ self.handlers.sync = self.sync
+ self.identity = identity
+ self.reticulum = reticulum
+ self._started = False
+
+ def is_enabled(self):
+ return self.fdb.get_setting(FORUM_ENABLED_KEY, "0") == "1"
+
+ def enable(self):
+ self.fdb.set_setting(FORUM_ENABLED_KEY, "1")
+ if not self._started:
+ self.sync.start()
+ self._started = True
+
+ def disable(self):
+ self.fdb.set_setting(FORUM_ENABLED_KEY, "0")
+ # Keep sync running so we still receive content — disable just hides UI
+
+ def handle(self, method, path, query, body, cookies=None):
+ return self.handlers.handle(method, path, query, body, cookies)
diff --git a/tinyweb_forum/db.py b/tinyweb_forum/db.py
new file mode 100644
index 0000000..a38ad5c
--- /dev/null
+++ b/tinyweb_forum/db.py
@@ -0,0 +1,321 @@
+import sqlite3
+import os
+import threading
+
+FORUM_DB = "forum.db"
+
+
+class ForumDB:
+ def __init__(self, data_dir):
+ self.path = os.path.join(data_dir, FORUM_DB)
+ self._pool = []
+ self._pool_lock = threading.Lock()
+ self._POOL_SIZE = 8
+ self.init_db()
+
+ def init_db(self):
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
+ db = sqlite3.connect(self.path)
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS threads ("
+ " id TEXT PRIMARY KEY,"
+ " title TEXT NOT NULL,"
+ " url TEXT DEFAULT '',"
+ " body TEXT DEFAULT '',"
+ " tags TEXT DEFAULT '',"
+ " author_instance TEXT NOT NULL,"
+ " author_name TEXT DEFAULT '',"
+ " created_at TEXT NOT NULL,"
+ " updated_at TEXT NOT NULL,"
+ " score INTEGER DEFAULT 0"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS posts ("
+ " id TEXT PRIMARY KEY,"
+ " thread_id TEXT NOT NULL,"
+ " parent_id TEXT DEFAULT '',"
+ " body TEXT NOT NULL,"
+ " author_instance TEXT NOT NULL,"
+ " author_name TEXT DEFAULT '',"
+ " created_at TEXT NOT NULL,"
+ " FOREIGN KEY (thread_id) REFERENCES threads(id)"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS upvotes ("
+ " thread_id TEXT NOT NULL,"
+ " instance_hash TEXT NOT NULL,"
+ " PRIMARY KEY (thread_id, instance_hash)"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS synced_instances ("
+ " instance_hash TEXT PRIMARY KEY,"
+ " name TEXT DEFAULT '',"
+ " last_sync TEXT DEFAULT ''"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS settings ("
+ " key TEXT PRIMARY KEY,"
+ " value TEXT"
+ ")"
+ )
+ db.execute("CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id)")
+ db.execute("CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at)")
+ db.execute("CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at)")
+ db.execute("CREATE INDEX IF NOT EXISTS idx_threads_created ON threads(created_at)")
+ db.commit()
+ db.close()
+
+ def get_db(self):
+ with self._pool_lock:
+ if self._pool:
+ db = self._pool.pop()
+ try:
+ db.execute("SELECT 1")
+ return db
+ except Exception:
+ pass
+ db = sqlite3.connect(self.path, timeout=10)
+ db.execute("PRAGMA journal_mode=WAL")
+ db.row_factory = sqlite3.Row
+ return db
+
+ def return_db(self, db):
+ try:
+ db.rollback()
+ except Exception:
+ try:
+ db.close()
+ except Exception:
+ pass
+ return
+ with self._pool_lock:
+ if len(self._pool) < self._POOL_SIZE:
+ self._pool.append(db)
+ else:
+ db.close()
+
+ def get_setting(self, key, default=""):
+ db = self.get_db()
+ try:
+ row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
+ return row["value"] if row else default
+ finally:
+ self.return_db(db)
+
+ def set_setting(self, key, value):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT INTO settings (key, value) VALUES (?, ?) "
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
+ (key, value),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def get_thread(self, thread_id):
+ db = self.get_db()
+ try:
+ return db.execute(
+ "SELECT * FROM threads WHERE id = ?", (thread_id,)
+ ).fetchone()
+ finally:
+ self.return_db(db)
+
+ def get_posts(self, thread_id):
+ db = self.get_db()
+ try:
+ return db.execute(
+ "SELECT * FROM posts WHERE thread_id = ? ORDER BY created_at ASC",
+ (thread_id,),
+ ).fetchall()
+ finally:
+ self.return_db(db)
+
+ def get_threads(self, page=1, per_page=20, tag="", search=""):
+ db = self.get_db()
+ try:
+ offset = (page - 1) * per_page
+ params = []
+ where = []
+ if tag:
+ where.append("t.tags LIKE ?")
+ params.append(f"%{tag}%")
+ if search:
+ where.append("(t.title LIKE ? OR t.body LIKE ?)")
+ params.extend([f"%{search}%", f"%{search}%"])
+ where_clause = (" WHERE " + " AND ".join(where)) if where else ""
+ total = db.execute(
+ f"SELECT count(*) FROM threads t{where_clause}", params
+ ).fetchone()[0]
+ rows = db.execute(
+ f"SELECT t.*, (SELECT count(*) FROM posts p WHERE p.thread_id = t.id) AS reply_count "
+ f"FROM threads t{where_clause} ORDER BY t.updated_at DESC LIMIT ? OFFSET ?",
+ params + [per_page, offset],
+ ).fetchall()
+ return rows, total
+ finally:
+ self.return_db(db)
+
+ def create_thread(self, thread_id, title, url, body, tags, author_instance, author_name, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT INTO threads (id, title, url, body, tags, author_instance, author_name, created_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (thread_id, title, url, body, tags, author_instance, author_name, now, now),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def create_post(self, post_id, thread_id, parent_id, body, author_instance, author_name, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT INTO posts (id, thread_id, parent_id, body, author_instance, author_name, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (post_id, thread_id, parent_id, body, author_instance, author_name, now),
+ )
+ db.execute("UPDATE threads SET updated_at = ? WHERE id = ?", (now, thread_id))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def toggle_upvote(self, thread_id, instance_hash):
+ db = self.get_db()
+ try:
+ row = db.execute(
+ "SELECT 1 FROM upvotes WHERE thread_id = ? AND instance_hash = ?",
+ (thread_id, instance_hash),
+ ).fetchone()
+ if row:
+ db.execute(
+ "DELETE FROM upvotes WHERE thread_id = ? AND instance_hash = ?",
+ (thread_id, instance_hash),
+ )
+ db.execute("UPDATE threads SET score = score - 1 WHERE id = ?", (thread_id,))
+ delta = -1
+ else:
+ db.execute(
+ "INSERT INTO upvotes (thread_id, instance_hash) VALUES (?, ?)",
+ (thread_id, instance_hash),
+ )
+ db.execute("UPDATE threads SET score = score + 1 WHERE id = ?", (thread_id,))
+ delta = 1
+ db.commit()
+ return delta
+ finally:
+ self.return_db(db)
+
+ def get_synced_instances(self):
+ db = self.get_db()
+ try:
+ return db.execute("SELECT * FROM synced_instances").fetchall()
+ finally:
+ self.return_db(db)
+
+ def upsert_synced_instance(self, instance_hash, name=""):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT INTO synced_instances (instance_hash, name) VALUES (?, ?) "
+ "ON CONFLICT(instance_hash) DO UPDATE SET name=excluded.name",
+ (instance_hash, name),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def remove_synced_instance(self, instance_hash):
+ db = self.get_db()
+ try:
+ db.execute("DELETE FROM synced_instances WHERE instance_hash = ?", (instance_hash,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def update_last_sync(self, instance_hash, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "UPDATE synced_instances SET last_sync = ? WHERE instance_hash = ?",
+ (now, instance_hash),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def set_last_sync(self, instance_hash, timestamp):
+ self.update_last_sync(instance_hash, timestamp)
+
+ def get_new_content(self, since):
+ db = self.get_db()
+ try:
+ threads = db.execute(
+ "SELECT * FROM threads WHERE updated_at > ? ORDER BY updated_at ASC",
+ (since,),
+ ).fetchall()
+ posts = db.execute(
+ "SELECT * FROM posts WHERE created_at > ? ORDER BY created_at ASC",
+ (since,),
+ ).fetchall()
+ upvote_threads = db.execute(
+ "SELECT thread_id FROM upvotes u "
+ "WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = u.thread_id AND t.updated_at > ?)",
+ (since,),
+ ).fetchall()
+ return threads, posts, [r["thread_id"] for r in upvote_threads]
+ finally:
+ self.return_db(db)
+
+ def merge_thread(self, thread):
+ db = self.get_db()
+ try:
+ existing = db.execute("SELECT updated_at FROM threads WHERE id = ?", (thread["id"],)).fetchone()
+ if existing and existing["updated_at"] >= thread["updated_at"]:
+ return
+ db.execute(
+ "INSERT INTO threads (id, title, url, body, tags, author_instance, author_name, created_at, updated_at, score) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(id) DO UPDATE SET "
+ "title=excluded.title, url=excluded.url, body=excluded.body, "
+ "tags=excluded.tags, updated_at=excluded.updated_at",
+ (thread["id"], thread["title"], thread["url"], thread["body"],
+ thread["tags"], thread["author_instance"], thread["author_name"],
+ thread["created_at"], thread["updated_at"], thread["score"]),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def merge_post(self, post):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR IGNORE INTO posts (id, thread_id, parent_id, body, author_instance, author_name, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (post["id"], post["thread_id"], post["parent_id"], post["body"],
+ post["author_instance"], post["author_name"], post["created_at"]),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def merge_upvote(self, thread_id, instance_hash):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR IGNORE INTO upvotes (thread_id, instance_hash) VALUES (?, ?)",
+ (thread_id, instance_hash),
+ )
+ if db.total_changes:
+ db.execute("UPDATE threads SET score = score + 1 WHERE id = ?", (thread_id,))
+ db.commit()
+ finally:
+ self.return_db(db)
diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py
new file mode 100644
index 0000000..c44bfa8
--- /dev/null
+++ b/tinyweb_forum/handlers.py
@@ -0,0 +1,538 @@
+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''
+ )
+ tag_label = f' — tag: {esc(tag)}' if tag else ""
+ return self._respond(
+ 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''
+ if is_muted else
+ f''
+ )
+
+ 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'
'
+ 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' ·
"
+
+ 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'
'
+ )
+ synced_items = f"
{synced_items}
" if synced_items else "
No instances synced yet.
"
+
+ return self._respond(
+ f"
forum moderation
"
+ f"
{msg}
"
+ f"
blocked instances
"
+ f"{blocked_items}"
+ f'"
+ f"
keyword filters
"
+ f'"
+ f"
synced instances
"
+ f"{synced_items}"
+ 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)
diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py
new file mode 100644
index 0000000..32133b8
--- /dev/null
+++ b/tinyweb_forum/sync.py
@@ -0,0 +1,146 @@
+import json
+import threading
+import time
+import RNS
+
+FORUM_APP = "tinyweb-forum"
+SYNC_INTERVAL = 300 # 5 minutes
+REQUEST_TIMEOUT = 60
+
+
+class ForumSync:
+ def __init__(self, fdb, identity, reticulum, handlers_ref):
+ self.fdb = fdb
+ self.identity = identity
+ self.reticulum = reticulum
+ self.handlers_ref = handlers_ref
+ self.destination = None
+ self._running = False
+ self._thread = None
+
+ def start(self):
+ self.destination = RNS.Destination(
+ self.identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ FORUM_APP,
+ )
+ self.destination.register_request_handler(
+ "/forum",
+ response_generator=self._rns_handler,
+ allow=RNS.Destination.ALLOW_ALL,
+ )
+ self.destination.announce()
+ self._running = True
+ self._thread = threading.Thread(target=self._sync_loop, daemon=True)
+ self._thread.start()
+
+ def stop(self):
+ self._running = False
+
+ def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
+ return self.handlers_ref().handle_sync(data)
+
+ def _sync_loop(self):
+ while self._running:
+ try:
+ instances = self.fdb.get_synced_instances()
+ for inst in instances:
+ if not self._running:
+ break
+ try:
+ self._sync_with(inst["instance_hash"])
+ except Exception as e:
+ print(f"[forum] sync error with {inst['instance_hash'][:16]}: {e}")
+ except Exception:
+ pass
+ for _ in range(SYNC_INTERVAL):
+ if not self._running:
+ return
+ time.sleep(1)
+
+ def _sync_with(self, instance_hash):
+ dest_hash = bytes.fromhex(instance_hash)
+ if not RNS.Transport.has_path(dest_hash):
+ RNS.Transport.request_path(dest_hash)
+ elapsed = 0
+ while not RNS.Transport.has_path(dest_hash) and elapsed < 15:
+ time.sleep(0.5)
+ elapsed += 0.5
+ if not RNS.Transport.has_path(dest_hash):
+ return
+
+ server_identity = RNS.Identity.recall(dest_hash)
+ if server_identity is None:
+ return
+
+ destination = RNS.Destination(
+ server_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ FORUM_APP,
+ )
+
+ link = RNS.Link(destination)
+ elapsed = 0
+ while link.status == RNS.Link.PENDING and elapsed < 15:
+ time.sleep(0.25)
+ elapsed += 0.25
+
+ if link.status != RNS.Link.ACTIVE:
+ return
+
+ try:
+ inst = self.fdb.get_synced_instances()
+ last_sync = ""
+ for s in inst:
+ if s["instance_hash"] == instance_hash:
+ last_sync = s["last_sync"] or ""
+ break
+
+ since = last_sync.replace(" ", "T") if last_sync else ""
+
+ threads, posts = [], []
+ upvotes = []
+ if since:
+ ts, ps, uv = self.fdb.get_new_content(since)
+ threads = [dict(r) for r in ts]
+ posts = [dict(r) for r in ps]
+ upvotes = [{"thread_id": tid, "instance_hash": instance_hash} for tid in uv]
+
+ request_data = {
+ "query": {"since": [since]} if since else {},
+ "threads": threads,
+ "posts": posts,
+ "upvotes": upvotes,
+ }
+
+ receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
+ elapsed = 0
+ done = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED)
+ while receipt.get_status() not in done and elapsed < REQUEST_TIMEOUT:
+ time.sleep(0.1)
+ elapsed += 0.1
+
+ if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED):
+ resp = receipt.get_response()
+ if isinstance(resp, dict) and resp.get("status") == 200:
+ try:
+ data = json.loads(resp["body"])
+ except (json.JSONDecodeError, KeyError):
+ data = {}
+ for t in data.get("threads", []):
+ self.fdb.merge_thread(t)
+ for p in data.get("posts", []):
+ self.fdb.merge_post(p)
+ for tid in data.get("upvote_threads", []):
+ self.fdb.merge_upvote(tid, instance_hash)
+ now = time.strftime("%Y-%m-%dT%H:%M:%S")
+ self.fdb.set_last_sync(instance_hash, now)
+ else:
+ pass
+ finally:
+ link.teardown()
+
+ def handle_sync(self, data):
+ return self.handlers_ref().handle_sync_request(data)
From b057d93b13766aa30d747751436ee9f5bd5ddd82 Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 4 Jun 2026 08:23:51 +0000
Subject: [PATCH 02/58] initial: decentralized link-sharing forum for TinyWeb
---
.gitignore | 5 +
README.md | 25 ++
pyproject.toml | 19 ++
tinyweb_forum/__init__.py | 34 +++
tinyweb_forum/db.py | 321 +++++++++++++++++++++++
tinyweb_forum/handlers.py | 538 ++++++++++++++++++++++++++++++++++++++
tinyweb_forum/sync.py | 146 +++++++++++
7 files changed, 1088 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 pyproject.toml
create mode 100644 tinyweb_forum/__init__.py
create mode 100644 tinyweb_forum/db.py
create mode 100644 tinyweb_forum/handlers.py
create mode 100644 tinyweb_forum/sync.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a376320
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.pyc
+*.pyo
+dist/
+*.egg-info/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4508429
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# tinyweb-forum
+
+A decentralized link-sharing forum for [TinyWeb](https://github.com/derickfay/tinyweb). Share URLs and discuss them with other TinyWeb instances over the Reticulum mesh.
+
+## Install
+
+```bash
+pip install tinyweb-forum
+```
+
+Enable the forum in TinyWeb's customize page (`/style`).
+
+## Development
+
+```bash
+git clone https://github.com/derickfay/tinyweb-forum
+pip install -e .
+```
+
+## How it works
+
+- Each TinyWeb instance stores forum threads and posts in its own `forum.db`
+- Instances sync content with each other over RNS
+- Moderation is per-instance: block instances, mute threads, keyword filters
+- No global server, no algorithms, no tracking
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..19a0bb7
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,19 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.backends._legacy:_Backend"
+
+[project]
+name = "tinyweb-forum"
+version = "0.1.0"
+description = "Decentralized link-sharing forum for TinyWeb"
+license = {text = "MIT"}
+requires-python = ">=3.9"
+dependencies = [
+ "rns",
+]
+
+[project.optional-dependencies]
+dev = []
+
+[tool.setuptools.packages.find]
+include = ["tinyweb_forum*"]
diff --git a/tinyweb_forum/__init__.py b/tinyweb_forum/__init__.py
new file mode 100644
index 0000000..ffdbc9c
--- /dev/null
+++ b/tinyweb_forum/__init__.py
@@ -0,0 +1,34 @@
+from tinyweb_forum.db import ForumDB
+from tinyweb_forum.handlers import ForumHandlers
+from tinyweb_forum.sync import ForumSync
+
+FORUM_ENABLED_KEY = "forum_enabled"
+
+
+class ForumPlugin:
+ def __init__(self, data_dir, identity, reticulum, site_name="me"):
+ self.fdb = ForumDB(data_dir)
+ self.handlers = ForumHandlers(
+ self.fdb, None, identity, reticulum, site_name=site_name
+ )
+ self.sync = ForumSync(self.fdb, identity, reticulum, lambda: self.handlers)
+ self.handlers.sync = self.sync
+ self.identity = identity
+ self.reticulum = reticulum
+ self._started = False
+
+ def is_enabled(self):
+ return self.fdb.get_setting(FORUM_ENABLED_KEY, "0") == "1"
+
+ def enable(self):
+ self.fdb.set_setting(FORUM_ENABLED_KEY, "1")
+ if not self._started:
+ self.sync.start()
+ self._started = True
+
+ def disable(self):
+ self.fdb.set_setting(FORUM_ENABLED_KEY, "0")
+ # Keep sync running so we still receive content — disable just hides UI
+
+ def handle(self, method, path, query, body, cookies=None):
+ return self.handlers.handle(method, path, query, body, cookies)
diff --git a/tinyweb_forum/db.py b/tinyweb_forum/db.py
new file mode 100644
index 0000000..a38ad5c
--- /dev/null
+++ b/tinyweb_forum/db.py
@@ -0,0 +1,321 @@
+import sqlite3
+import os
+import threading
+
+FORUM_DB = "forum.db"
+
+
+class ForumDB:
+ def __init__(self, data_dir):
+ self.path = os.path.join(data_dir, FORUM_DB)
+ self._pool = []
+ self._pool_lock = threading.Lock()
+ self._POOL_SIZE = 8
+ self.init_db()
+
+ def init_db(self):
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
+ db = sqlite3.connect(self.path)
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS threads ("
+ " id TEXT PRIMARY KEY,"
+ " title TEXT NOT NULL,"
+ " url TEXT DEFAULT '',"
+ " body TEXT DEFAULT '',"
+ " tags TEXT DEFAULT '',"
+ " author_instance TEXT NOT NULL,"
+ " author_name TEXT DEFAULT '',"
+ " created_at TEXT NOT NULL,"
+ " updated_at TEXT NOT NULL,"
+ " score INTEGER DEFAULT 0"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS posts ("
+ " id TEXT PRIMARY KEY,"
+ " thread_id TEXT NOT NULL,"
+ " parent_id TEXT DEFAULT '',"
+ " body TEXT NOT NULL,"
+ " author_instance TEXT NOT NULL,"
+ " author_name TEXT DEFAULT '',"
+ " created_at TEXT NOT NULL,"
+ " FOREIGN KEY (thread_id) REFERENCES threads(id)"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS upvotes ("
+ " thread_id TEXT NOT NULL,"
+ " instance_hash TEXT NOT NULL,"
+ " PRIMARY KEY (thread_id, instance_hash)"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS synced_instances ("
+ " instance_hash TEXT PRIMARY KEY,"
+ " name TEXT DEFAULT '',"
+ " last_sync TEXT DEFAULT ''"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS settings ("
+ " key TEXT PRIMARY KEY,"
+ " value TEXT"
+ ")"
+ )
+ db.execute("CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id)")
+ db.execute("CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at)")
+ db.execute("CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at)")
+ db.execute("CREATE INDEX IF NOT EXISTS idx_threads_created ON threads(created_at)")
+ db.commit()
+ db.close()
+
+ def get_db(self):
+ with self._pool_lock:
+ if self._pool:
+ db = self._pool.pop()
+ try:
+ db.execute("SELECT 1")
+ return db
+ except Exception:
+ pass
+ db = sqlite3.connect(self.path, timeout=10)
+ db.execute("PRAGMA journal_mode=WAL")
+ db.row_factory = sqlite3.Row
+ return db
+
+ def return_db(self, db):
+ try:
+ db.rollback()
+ except Exception:
+ try:
+ db.close()
+ except Exception:
+ pass
+ return
+ with self._pool_lock:
+ if len(self._pool) < self._POOL_SIZE:
+ self._pool.append(db)
+ else:
+ db.close()
+
+ def get_setting(self, key, default=""):
+ db = self.get_db()
+ try:
+ row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
+ return row["value"] if row else default
+ finally:
+ self.return_db(db)
+
+ def set_setting(self, key, value):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT INTO settings (key, value) VALUES (?, ?) "
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
+ (key, value),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def get_thread(self, thread_id):
+ db = self.get_db()
+ try:
+ return db.execute(
+ "SELECT * FROM threads WHERE id = ?", (thread_id,)
+ ).fetchone()
+ finally:
+ self.return_db(db)
+
+ def get_posts(self, thread_id):
+ db = self.get_db()
+ try:
+ return db.execute(
+ "SELECT * FROM posts WHERE thread_id = ? ORDER BY created_at ASC",
+ (thread_id,),
+ ).fetchall()
+ finally:
+ self.return_db(db)
+
+ def get_threads(self, page=1, per_page=20, tag="", search=""):
+ db = self.get_db()
+ try:
+ offset = (page - 1) * per_page
+ params = []
+ where = []
+ if tag:
+ where.append("t.tags LIKE ?")
+ params.append(f"%{tag}%")
+ if search:
+ where.append("(t.title LIKE ? OR t.body LIKE ?)")
+ params.extend([f"%{search}%", f"%{search}%"])
+ where_clause = (" WHERE " + " AND ".join(where)) if where else ""
+ total = db.execute(
+ f"SELECT count(*) FROM threads t{where_clause}", params
+ ).fetchone()[0]
+ rows = db.execute(
+ f"SELECT t.*, (SELECT count(*) FROM posts p WHERE p.thread_id = t.id) AS reply_count "
+ f"FROM threads t{where_clause} ORDER BY t.updated_at DESC LIMIT ? OFFSET ?",
+ params + [per_page, offset],
+ ).fetchall()
+ return rows, total
+ finally:
+ self.return_db(db)
+
+ def create_thread(self, thread_id, title, url, body, tags, author_instance, author_name, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT INTO threads (id, title, url, body, tags, author_instance, author_name, created_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (thread_id, title, url, body, tags, author_instance, author_name, now, now),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def create_post(self, post_id, thread_id, parent_id, body, author_instance, author_name, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT INTO posts (id, thread_id, parent_id, body, author_instance, author_name, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (post_id, thread_id, parent_id, body, author_instance, author_name, now),
+ )
+ db.execute("UPDATE threads SET updated_at = ? WHERE id = ?", (now, thread_id))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def toggle_upvote(self, thread_id, instance_hash):
+ db = self.get_db()
+ try:
+ row = db.execute(
+ "SELECT 1 FROM upvotes WHERE thread_id = ? AND instance_hash = ?",
+ (thread_id, instance_hash),
+ ).fetchone()
+ if row:
+ db.execute(
+ "DELETE FROM upvotes WHERE thread_id = ? AND instance_hash = ?",
+ (thread_id, instance_hash),
+ )
+ db.execute("UPDATE threads SET score = score - 1 WHERE id = ?", (thread_id,))
+ delta = -1
+ else:
+ db.execute(
+ "INSERT INTO upvotes (thread_id, instance_hash) VALUES (?, ?)",
+ (thread_id, instance_hash),
+ )
+ db.execute("UPDATE threads SET score = score + 1 WHERE id = ?", (thread_id,))
+ delta = 1
+ db.commit()
+ return delta
+ finally:
+ self.return_db(db)
+
+ def get_synced_instances(self):
+ db = self.get_db()
+ try:
+ return db.execute("SELECT * FROM synced_instances").fetchall()
+ finally:
+ self.return_db(db)
+
+ def upsert_synced_instance(self, instance_hash, name=""):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT INTO synced_instances (instance_hash, name) VALUES (?, ?) "
+ "ON CONFLICT(instance_hash) DO UPDATE SET name=excluded.name",
+ (instance_hash, name),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def remove_synced_instance(self, instance_hash):
+ db = self.get_db()
+ try:
+ db.execute("DELETE FROM synced_instances WHERE instance_hash = ?", (instance_hash,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def update_last_sync(self, instance_hash, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "UPDATE synced_instances SET last_sync = ? WHERE instance_hash = ?",
+ (now, instance_hash),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def set_last_sync(self, instance_hash, timestamp):
+ self.update_last_sync(instance_hash, timestamp)
+
+ def get_new_content(self, since):
+ db = self.get_db()
+ try:
+ threads = db.execute(
+ "SELECT * FROM threads WHERE updated_at > ? ORDER BY updated_at ASC",
+ (since,),
+ ).fetchall()
+ posts = db.execute(
+ "SELECT * FROM posts WHERE created_at > ? ORDER BY created_at ASC",
+ (since,),
+ ).fetchall()
+ upvote_threads = db.execute(
+ "SELECT thread_id FROM upvotes u "
+ "WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = u.thread_id AND t.updated_at > ?)",
+ (since,),
+ ).fetchall()
+ return threads, posts, [r["thread_id"] for r in upvote_threads]
+ finally:
+ self.return_db(db)
+
+ def merge_thread(self, thread):
+ db = self.get_db()
+ try:
+ existing = db.execute("SELECT updated_at FROM threads WHERE id = ?", (thread["id"],)).fetchone()
+ if existing and existing["updated_at"] >= thread["updated_at"]:
+ return
+ db.execute(
+ "INSERT INTO threads (id, title, url, body, tags, author_instance, author_name, created_at, updated_at, score) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(id) DO UPDATE SET "
+ "title=excluded.title, url=excluded.url, body=excluded.body, "
+ "tags=excluded.tags, updated_at=excluded.updated_at",
+ (thread["id"], thread["title"], thread["url"], thread["body"],
+ thread["tags"], thread["author_instance"], thread["author_name"],
+ thread["created_at"], thread["updated_at"], thread["score"]),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def merge_post(self, post):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR IGNORE INTO posts (id, thread_id, parent_id, body, author_instance, author_name, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (post["id"], post["thread_id"], post["parent_id"], post["body"],
+ post["author_instance"], post["author_name"], post["created_at"]),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def merge_upvote(self, thread_id, instance_hash):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR IGNORE INTO upvotes (thread_id, instance_hash) VALUES (?, ?)",
+ (thread_id, instance_hash),
+ )
+ if db.total_changes:
+ db.execute("UPDATE threads SET score = score + 1 WHERE id = ?", (thread_id,))
+ db.commit()
+ finally:
+ self.return_db(db)
diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py
new file mode 100644
index 0000000..c44bfa8
--- /dev/null
+++ b/tinyweb_forum/handlers.py
@@ -0,0 +1,538 @@
+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''
+ )
+ tag_label = f' — tag: {esc(tag)}' if tag else ""
+ return self._respond(
+ 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''
+ if is_muted else
+ f''
+ )
+
+ 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'
'
+ 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' ·
"
+
+ 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'
'
+ )
+ synced_items = f"
{synced_items}
" if synced_items else "
No instances synced yet.
"
+
+ return self._respond(
+ f"
forum moderation
"
+ f"
{msg}
"
+ f"
blocked instances
"
+ f"{blocked_items}"
+ f'"
+ f"
keyword filters
"
+ f'"
+ f"
synced instances
"
+ f"{synced_items}"
+ 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)
diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py
new file mode 100644
index 0000000..32133b8
--- /dev/null
+++ b/tinyweb_forum/sync.py
@@ -0,0 +1,146 @@
+import json
+import threading
+import time
+import RNS
+
+FORUM_APP = "tinyweb-forum"
+SYNC_INTERVAL = 300 # 5 minutes
+REQUEST_TIMEOUT = 60
+
+
+class ForumSync:
+ def __init__(self, fdb, identity, reticulum, handlers_ref):
+ self.fdb = fdb
+ self.identity = identity
+ self.reticulum = reticulum
+ self.handlers_ref = handlers_ref
+ self.destination = None
+ self._running = False
+ self._thread = None
+
+ def start(self):
+ self.destination = RNS.Destination(
+ self.identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ FORUM_APP,
+ )
+ self.destination.register_request_handler(
+ "/forum",
+ response_generator=self._rns_handler,
+ allow=RNS.Destination.ALLOW_ALL,
+ )
+ self.destination.announce()
+ self._running = True
+ self._thread = threading.Thread(target=self._sync_loop, daemon=True)
+ self._thread.start()
+
+ def stop(self):
+ self._running = False
+
+ def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
+ return self.handlers_ref().handle_sync(data)
+
+ def _sync_loop(self):
+ while self._running:
+ try:
+ instances = self.fdb.get_synced_instances()
+ for inst in instances:
+ if not self._running:
+ break
+ try:
+ self._sync_with(inst["instance_hash"])
+ except Exception as e:
+ print(f"[forum] sync error with {inst['instance_hash'][:16]}: {e}")
+ except Exception:
+ pass
+ for _ in range(SYNC_INTERVAL):
+ if not self._running:
+ return
+ time.sleep(1)
+
+ def _sync_with(self, instance_hash):
+ dest_hash = bytes.fromhex(instance_hash)
+ if not RNS.Transport.has_path(dest_hash):
+ RNS.Transport.request_path(dest_hash)
+ elapsed = 0
+ while not RNS.Transport.has_path(dest_hash) and elapsed < 15:
+ time.sleep(0.5)
+ elapsed += 0.5
+ if not RNS.Transport.has_path(dest_hash):
+ return
+
+ server_identity = RNS.Identity.recall(dest_hash)
+ if server_identity is None:
+ return
+
+ destination = RNS.Destination(
+ server_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ FORUM_APP,
+ )
+
+ link = RNS.Link(destination)
+ elapsed = 0
+ while link.status == RNS.Link.PENDING and elapsed < 15:
+ time.sleep(0.25)
+ elapsed += 0.25
+
+ if link.status != RNS.Link.ACTIVE:
+ return
+
+ try:
+ inst = self.fdb.get_synced_instances()
+ last_sync = ""
+ for s in inst:
+ if s["instance_hash"] == instance_hash:
+ last_sync = s["last_sync"] or ""
+ break
+
+ since = last_sync.replace(" ", "T") if last_sync else ""
+
+ threads, posts = [], []
+ upvotes = []
+ if since:
+ ts, ps, uv = self.fdb.get_new_content(since)
+ threads = [dict(r) for r in ts]
+ posts = [dict(r) for r in ps]
+ upvotes = [{"thread_id": tid, "instance_hash": instance_hash} for tid in uv]
+
+ request_data = {
+ "query": {"since": [since]} if since else {},
+ "threads": threads,
+ "posts": posts,
+ "upvotes": upvotes,
+ }
+
+ receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
+ elapsed = 0
+ done = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED)
+ while receipt.get_status() not in done and elapsed < REQUEST_TIMEOUT:
+ time.sleep(0.1)
+ elapsed += 0.1
+
+ if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED):
+ resp = receipt.get_response()
+ if isinstance(resp, dict) and resp.get("status") == 200:
+ try:
+ data = json.loads(resp["body"])
+ except (json.JSONDecodeError, KeyError):
+ data = {}
+ for t in data.get("threads", []):
+ self.fdb.merge_thread(t)
+ for p in data.get("posts", []):
+ self.fdb.merge_post(p)
+ for tid in data.get("upvote_threads", []):
+ self.fdb.merge_upvote(tid, instance_hash)
+ now = time.strftime("%Y-%m-%dT%H:%M:%S")
+ self.fdb.set_last_sync(instance_hash, now)
+ else:
+ pass
+ finally:
+ link.teardown()
+
+ def handle_sync(self, data):
+ return self.handlers_ref().handle_sync_request(data)
From 41e0d81715d0c2705d6c7da3013a8e08d3c3e274 Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:32:30 +0000
Subject: [PATCH 03/58] fix README URLs, handler tests, sync improvements,
block/retract gossip
---
README.md | 33 +++++-
tinyweb_forum/db.py | 156 ++++++++++++++++++++++++
tinyweb_forum/handlers.py | 242 +++++++++++++++++++++++++++++++++-----
tinyweb_forum/sync.py | 45 ++++++-
4 files changed, 440 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md
index 4508429..43b0941 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# tinyweb-forum
-A decentralized link-sharing forum for [TinyWeb](https://github.com/derickfay/tinyweb). Share URLs and discuss them with other TinyWeb instances over the Reticulum mesh.
+A decentralized link-sharing forum for [TinyWeb](https://git.derickphan.com/blankie/tinyweb). Share URLs and discuss them with other TinyWeb instances over the Reticulum mesh.
## Install
@@ -13,13 +13,38 @@ Enable the forum in TinyWeb's customize page (`/style`).
## Development
```bash
-git clone https://github.com/derickfay/tinyweb-forum
+git clone https://git.derickphan.com/blankie/tinyweb-forum
pip install -e .
```
## How it works
- Each TinyWeb instance stores forum threads and posts in its own `forum.db`
-- Instances sync content with each other over RNS
-- Moderation is per-instance: block instances, mute threads, keyword filters
+- Instances sync content with each other over RNS every 5 minutes
+- Authors are identified by a short pseudonymous hash (no names, no accounts)
- No global server, no algorithms, no tracking
+
+## Features
+
+- **Threads** — share a URL or start a discussion with text
+- **Replies** — reply to threads, with inline URL extraction and "+ save" links
+- **Upvotes** — toggle upvote/downvote, scores propagate via sync
+- **Edit** — edit your own threads (new version syncs to peers)
+- **Retract** — retract your own threads and posts (retraction signal gossips to peers)
+
+## Moderation
+
+All moderation is local — you control your view:
+
+- **Block author** — `[block]` link on posts and thread meta hides all content from that identity across your instance
+- **Auto-block** — when 3+ of your peers have blocked the same identity, it's auto-blocked for you too (configurable threshold)
+- **Mute thread** — hide a thread from the listing
+- **Keyword filters** — hide threads matching keywords
+- **Instance sync** — choose which peers to sync with; unsync at any time
+
+## Sync
+
+- Forum instances discover each other via Reticulum
+- Content is exchanged as JSON over RNS links every 5 minutes
+- Block lists and retractions are gossiped alongside content
+- Only new/updated content is transferred (timestamp-based)
diff --git a/tinyweb_forum/db.py b/tinyweb_forum/db.py
index a38ad5c..087d8e3 100644
--- a/tinyweb_forum/db.py
+++ b/tinyweb_forum/db.py
@@ -66,6 +66,22 @@ class ForumDB:
db.execute("CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_threads_created ON threads(created_at)")
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS peer_blocks ("
+ " peer_hash TEXT NOT NULL,"
+ " blocked_hash TEXT NOT NULL,"
+ " PRIMARY KEY (peer_hash, blocked_hash)"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS retracted_content ("
+ " content_id TEXT NOT NULL,"
+ " content_type TEXT NOT NULL,"
+ " author_instance TEXT NOT NULL,"
+ " retracted_at TEXT NOT NULL,"
+ " PRIMARY KEY (content_id, content_type)"
+ ")"
+ )
db.commit()
db.close()
@@ -213,6 +229,16 @@ class ForumDB:
finally:
self.return_db(db)
+ def has_upvoted(self, thread_id, instance_hash):
+ db = self.get_db()
+ try:
+ return db.execute(
+ "SELECT 1 FROM upvotes WHERE thread_id = ? AND instance_hash = ?",
+ (thread_id, instance_hash),
+ ).fetchone() is not None
+ finally:
+ self.return_db(db)
+
def get_synced_instances(self):
db = self.get_db()
try:
@@ -274,6 +300,17 @@ class ForumDB:
finally:
self.return_db(db)
+ def update_thread(self, thread_id, title, url, body, tags, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "UPDATE threads SET title=?, url=?, body=?, tags=?, updated_at=? WHERE id=?",
+ (title, url, body, tags, now, thread_id),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
def merge_thread(self, thread):
db = self.get_db()
try:
@@ -319,3 +356,122 @@ class ForumDB:
db.commit()
finally:
self.return_db(db)
+
+ def record_peer_block(self, peer_hash, blocked_hash):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR IGNORE INTO peer_blocks (peer_hash, blocked_hash) VALUES (?, ?)",
+ (peer_hash, blocked_hash),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def get_peer_block_counts(self):
+ db = self.get_db()
+ try:
+ return {
+ r["blocked_hash"]: r["count"]
+ for r in db.execute(
+ "SELECT blocked_hash, count(*) as count FROM peer_blocks GROUP BY blocked_hash"
+ ).fetchall()
+ }
+ finally:
+ self.return_db(db)
+
+ def get_peer_block_list(self):
+ db = self.get_db()
+ try:
+ return [r["blocked_hash"] for r in db.execute(
+ "SELECT DISTINCT blocked_hash FROM peer_blocks"
+ ).fetchall()]
+ finally:
+ self.return_db(db)
+
+ def clear_peer_block(self, blocked_hash):
+ db = self.get_db()
+ try:
+ db.execute("DELETE FROM peer_blocks WHERE blocked_hash = ?", (blocked_hash,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def retract_thread(self, thread_id, author_instance, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR REPLACE INTO retracted_content (content_id, content_type, author_instance, retracted_at) "
+ "VALUES (?, 'thread', ?, ?)",
+ (thread_id, author_instance, now),
+ )
+ db.execute("UPDATE threads SET title='[retracted]', url='', body='', tags='', score=0 WHERE id=?",
+ (thread_id,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def retract_post(self, post_id, author_instance, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR REPLACE INTO retracted_content (content_id, content_type, author_instance, retracted_at) "
+ "VALUES (?, 'post', ?, ?)",
+ (post_id, author_instance, now),
+ )
+ db.execute("UPDATE posts SET body='[retracted]' WHERE id=?", (post_id,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def merge_retraction(self, content_id, content_type, author_instance, now):
+ db = self.get_db()
+ try:
+ existing = db.execute(
+ "SELECT retracted_at FROM retracted_content WHERE content_id=? AND content_type=?",
+ (content_id, content_type),
+ ).fetchone()
+ if existing and existing["retracted_at"] >= now:
+ return
+ if content_type == "thread":
+ t = db.execute("SELECT author_instance FROM threads WHERE id=?", (content_id,)).fetchone()
+ if t and t["author_instance"] == author_instance:
+ db.execute(
+ "INSERT OR REPLACE INTO retracted_content VALUES (?, ?, ?, ?)",
+ (content_id, content_type, author_instance, now),
+ )
+ db.execute("UPDATE threads SET title='[retracted]', url='', body='', tags='', score=0 WHERE id=?",
+ (content_id,))
+ elif content_type == "post":
+ p = db.execute("SELECT author_instance FROM posts WHERE id=?", (content_id,)).fetchone()
+ if p and p["author_instance"] == author_instance:
+ db.execute(
+ "INSERT OR REPLACE INTO retracted_content VALUES (?, ?, ?, ?)",
+ (content_id, content_type, author_instance, now),
+ )
+ db.execute("UPDATE posts SET body='[retracted]' WHERE id=?", (content_id,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def get_retracted_ids(self):
+ db = self.get_db()
+ try:
+ threads = set(r["content_id"] for r in db.execute(
+ "SELECT content_id FROM retracted_content WHERE content_type='thread'"
+ ).fetchall())
+ posts = set(r["content_id"] for r in db.execute(
+ "SELECT content_id FROM retracted_content WHERE content_type='post'"
+ ).fetchall())
+ return threads, posts
+ finally:
+ self.return_db(db)
+
+ def get_raw_retractions(self):
+ db = self.get_db()
+ try:
+ return db.execute(
+ "SELECT content_id, content_type, author_instance, retracted_at FROM retracted_content"
+ ).fetchall()
+ finally:
+ self.return_db(db)
diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py
index c44bfa8..874ced7 100644
--- a/tinyweb_forum/handlers.py
+++ b/tinyweb_forum/handlers.py
@@ -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' [block]'
+
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'
'
- f'{badge} '
+ f'{badge}{mute_badge} '
f'{esc(r["title"])}'
f'{tags_html}'
f' '
- f'{esc(r["author_name"] or r["author_instance"][:8])}'
+ 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}'
@@ -180,15 +202,19 @@ class ForumHandlers:
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"
'
+ f'{search_form}'
+ f' + new'
+ f' mod'
+ f' {muted_link}'
+ f"
"
f"
{total} threads{new_label}
"
f"
{items}
"
- 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"
{esc(thread['body'])}
" if thread["body"] else ""
mute_btn = (
- f''
+ f'unmute'
if is_muted else
- f''
+ f'mute'
)
posts_html = ""
@@ -267,8 +296,10 @@ class ForumHandlers:
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(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'
'
- 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' ·
"
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:
@@ -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' · 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'
{esc(h[:16])}... '
+ f'
{label}{esc(h[:16])}...{reports} '
f'"
+ f"
peer reports
"
+ f"{self._peer_reports_html()}"
f"
keyword filters
"
f'"
f"
synced instances
"
f"{synced_items}"
f'"
f''
@@ -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)
diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py
index 32133b8..024aa2d 100644
--- a/tinyweb_forum/sync.py
+++ b/tinyweb_forum/sync.py
@@ -39,6 +39,8 @@ class ForumSync:
self._running = False
def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
+ if remote_identity:
+ data["peer_hash"] = remote_identity.hash.hex()
return self.handlers_ref().handle_sync(data)
def _sync_loop(self):
@@ -108,11 +110,20 @@ class ForumSync:
posts = [dict(r) for r in ps]
upvotes = [{"thread_id": tid, "instance_hash": instance_hash} for tid in uv]
+ my_blocks = [h.strip() for h in self.fdb.get_setting("blocked_instances", "").split(",") if h.strip()]
+ my_peer_blocks = self.fdb.get_peer_block_list()
+
+ retracted = [{"id": cid, "type": ct, "author": ai, "at": ra}
+ for cid, ct, ai, ra in self.fdb.get_raw_retractions()]
+
request_data = {
"query": {"since": [since]} if since else {},
"threads": threads,
"posts": posts,
"upvotes": upvotes,
+ "from_hash": self.identity.hash.hex() if self.identity else "local",
+ "blocks": {"mine": my_blocks, "peers": my_peer_blocks},
+ "retractions": retracted,
}
receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
@@ -129,12 +140,28 @@ class ForumSync:
data = json.loads(resp["body"])
except (json.JSONDecodeError, KeyError):
data = {}
+ my_blocks = set(h.strip() for h in self.fdb.get_setting("blocked_instances", "").split(",") if h.strip())
for t in data.get("threads", []):
- self.fdb.merge_thread(t)
+ if t.get("author_instance", "") not in my_blocks:
+ self.fdb.merge_thread(t)
for p in data.get("posts", []):
- self.fdb.merge_post(p)
+ if p.get("author_instance", "") not in my_blocks:
+ self.fdb.merge_post(p)
for tid in data.get("upvote_threads", []):
self.fdb.merge_upvote(tid, instance_hash)
+ # Gossip blocks from peer
+ peer_blocks = data.get("blocks", {})
+ for h in peer_blocks.get("mine", []):
+ if h and h not in my_blocks and instance_hash:
+ self.fdb.record_peer_block(instance_hash, h)
+ for h in peer_blocks.get("peers", []):
+ if h and h not in my_blocks and instance_hash:
+ self.fdb.record_peer_block(instance_hash, h)
+ self._apply_peer_blocks()
+ # 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"])
now = time.strftime("%Y-%m-%dT%H:%M:%S")
self.fdb.set_last_sync(instance_hash, now)
else:
@@ -142,5 +169,19 @@ class ForumSync:
finally:
link.teardown()
+ def _apply_peer_blocks(self):
+ counts = self.fdb.get_peer_block_counts()
+ blocked = set(h.strip() for h in self.fdb.get_setting("blocked_instances", "").split(",") if h.strip())
+ auto_blocked = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
+ changed = False
+ for h, count in counts.items():
+ if h not in blocked and h not in auto_blocked and count >= 3:
+ blocked.add(h)
+ auto_blocked.add(h)
+ changed = True
+ if changed:
+ self.fdb.set_setting("blocked_instances", ",".join(blocked))
+ self.fdb.set_setting("auto_blocked_instances", ",".join(auto_blocked))
+
def handle_sync(self, data):
return self.handlers_ref().handle_sync_request(data)
From b2b454b33a9942a19e8245158a494a873c0b9e56 Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:32:30 +0000
Subject: [PATCH 04/58] fix README URLs, handler tests, sync improvements,
block/retract gossip
---
README.md | 33 +++++-
tinyweb_forum/db.py | 156 ++++++++++++++++++++++++
tinyweb_forum/handlers.py | 242 +++++++++++++++++++++++++++++++++-----
tinyweb_forum/sync.py | 45 ++++++-
4 files changed, 440 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md
index 4508429..43b0941 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# tinyweb-forum
-A decentralized link-sharing forum for [TinyWeb](https://github.com/derickfay/tinyweb). Share URLs and discuss them with other TinyWeb instances over the Reticulum mesh.
+A decentralized link-sharing forum for [TinyWeb](https://git.derickphan.com/blankie/tinyweb). Share URLs and discuss them with other TinyWeb instances over the Reticulum mesh.
## Install
@@ -13,13 +13,38 @@ Enable the forum in TinyWeb's customize page (`/style`).
## Development
```bash
-git clone https://github.com/derickfay/tinyweb-forum
+git clone https://git.derickphan.com/blankie/tinyweb-forum
pip install -e .
```
## How it works
- Each TinyWeb instance stores forum threads and posts in its own `forum.db`
-- Instances sync content with each other over RNS
-- Moderation is per-instance: block instances, mute threads, keyword filters
+- Instances sync content with each other over RNS every 5 minutes
+- Authors are identified by a short pseudonymous hash (no names, no accounts)
- No global server, no algorithms, no tracking
+
+## Features
+
+- **Threads** — share a URL or start a discussion with text
+- **Replies** — reply to threads, with inline URL extraction and "+ save" links
+- **Upvotes** — toggle upvote/downvote, scores propagate via sync
+- **Edit** — edit your own threads (new version syncs to peers)
+- **Retract** — retract your own threads and posts (retraction signal gossips to peers)
+
+## Moderation
+
+All moderation is local — you control your view:
+
+- **Block author** — `[block]` link on posts and thread meta hides all content from that identity across your instance
+- **Auto-block** — when 3+ of your peers have blocked the same identity, it's auto-blocked for you too (configurable threshold)
+- **Mute thread** — hide a thread from the listing
+- **Keyword filters** — hide threads matching keywords
+- **Instance sync** — choose which peers to sync with; unsync at any time
+
+## Sync
+
+- Forum instances discover each other via Reticulum
+- Content is exchanged as JSON over RNS links every 5 minutes
+- Block lists and retractions are gossiped alongside content
+- Only new/updated content is transferred (timestamp-based)
diff --git a/tinyweb_forum/db.py b/tinyweb_forum/db.py
index a38ad5c..087d8e3 100644
--- a/tinyweb_forum/db.py
+++ b/tinyweb_forum/db.py
@@ -66,6 +66,22 @@ class ForumDB:
db.execute("CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_threads_created ON threads(created_at)")
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS peer_blocks ("
+ " peer_hash TEXT NOT NULL,"
+ " blocked_hash TEXT NOT NULL,"
+ " PRIMARY KEY (peer_hash, blocked_hash)"
+ ")"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS retracted_content ("
+ " content_id TEXT NOT NULL,"
+ " content_type TEXT NOT NULL,"
+ " author_instance TEXT NOT NULL,"
+ " retracted_at TEXT NOT NULL,"
+ " PRIMARY KEY (content_id, content_type)"
+ ")"
+ )
db.commit()
db.close()
@@ -213,6 +229,16 @@ class ForumDB:
finally:
self.return_db(db)
+ def has_upvoted(self, thread_id, instance_hash):
+ db = self.get_db()
+ try:
+ return db.execute(
+ "SELECT 1 FROM upvotes WHERE thread_id = ? AND instance_hash = ?",
+ (thread_id, instance_hash),
+ ).fetchone() is not None
+ finally:
+ self.return_db(db)
+
def get_synced_instances(self):
db = self.get_db()
try:
@@ -274,6 +300,17 @@ class ForumDB:
finally:
self.return_db(db)
+ def update_thread(self, thread_id, title, url, body, tags, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "UPDATE threads SET title=?, url=?, body=?, tags=?, updated_at=? WHERE id=?",
+ (title, url, body, tags, now, thread_id),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
def merge_thread(self, thread):
db = self.get_db()
try:
@@ -319,3 +356,122 @@ class ForumDB:
db.commit()
finally:
self.return_db(db)
+
+ def record_peer_block(self, peer_hash, blocked_hash):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR IGNORE INTO peer_blocks (peer_hash, blocked_hash) VALUES (?, ?)",
+ (peer_hash, blocked_hash),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def get_peer_block_counts(self):
+ db = self.get_db()
+ try:
+ return {
+ r["blocked_hash"]: r["count"]
+ for r in db.execute(
+ "SELECT blocked_hash, count(*) as count FROM peer_blocks GROUP BY blocked_hash"
+ ).fetchall()
+ }
+ finally:
+ self.return_db(db)
+
+ def get_peer_block_list(self):
+ db = self.get_db()
+ try:
+ return [r["blocked_hash"] for r in db.execute(
+ "SELECT DISTINCT blocked_hash FROM peer_blocks"
+ ).fetchall()]
+ finally:
+ self.return_db(db)
+
+ def clear_peer_block(self, blocked_hash):
+ db = self.get_db()
+ try:
+ db.execute("DELETE FROM peer_blocks WHERE blocked_hash = ?", (blocked_hash,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def retract_thread(self, thread_id, author_instance, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR REPLACE INTO retracted_content (content_id, content_type, author_instance, retracted_at) "
+ "VALUES (?, 'thread', ?, ?)",
+ (thread_id, author_instance, now),
+ )
+ db.execute("UPDATE threads SET title='[retracted]', url='', body='', tags='', score=0 WHERE id=?",
+ (thread_id,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def retract_post(self, post_id, author_instance, now):
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR REPLACE INTO retracted_content (content_id, content_type, author_instance, retracted_at) "
+ "VALUES (?, 'post', ?, ?)",
+ (post_id, author_instance, now),
+ )
+ db.execute("UPDATE posts SET body='[retracted]' WHERE id=?", (post_id,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def merge_retraction(self, content_id, content_type, author_instance, now):
+ db = self.get_db()
+ try:
+ existing = db.execute(
+ "SELECT retracted_at FROM retracted_content WHERE content_id=? AND content_type=?",
+ (content_id, content_type),
+ ).fetchone()
+ if existing and existing["retracted_at"] >= now:
+ return
+ if content_type == "thread":
+ t = db.execute("SELECT author_instance FROM threads WHERE id=?", (content_id,)).fetchone()
+ if t and t["author_instance"] == author_instance:
+ db.execute(
+ "INSERT OR REPLACE INTO retracted_content VALUES (?, ?, ?, ?)",
+ (content_id, content_type, author_instance, now),
+ )
+ db.execute("UPDATE threads SET title='[retracted]', url='', body='', tags='', score=0 WHERE id=?",
+ (content_id,))
+ elif content_type == "post":
+ p = db.execute("SELECT author_instance FROM posts WHERE id=?", (content_id,)).fetchone()
+ if p and p["author_instance"] == author_instance:
+ db.execute(
+ "INSERT OR REPLACE INTO retracted_content VALUES (?, ?, ?, ?)",
+ (content_id, content_type, author_instance, now),
+ )
+ db.execute("UPDATE posts SET body='[retracted]' WHERE id=?", (content_id,))
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def get_retracted_ids(self):
+ db = self.get_db()
+ try:
+ threads = set(r["content_id"] for r in db.execute(
+ "SELECT content_id FROM retracted_content WHERE content_type='thread'"
+ ).fetchall())
+ posts = set(r["content_id"] for r in db.execute(
+ "SELECT content_id FROM retracted_content WHERE content_type='post'"
+ ).fetchall())
+ return threads, posts
+ finally:
+ self.return_db(db)
+
+ def get_raw_retractions(self):
+ db = self.get_db()
+ try:
+ return db.execute(
+ "SELECT content_id, content_type, author_instance, retracted_at FROM retracted_content"
+ ).fetchall()
+ finally:
+ self.return_db(db)
diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py
index c44bfa8..874ced7 100644
--- a/tinyweb_forum/handlers.py
+++ b/tinyweb_forum/handlers.py
@@ -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' [block]'
+
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'
'
- f'{badge} '
+ f'{badge}{mute_badge} '
f'{esc(r["title"])}'
f'{tags_html}'
f' '
- f'{esc(r["author_name"] or r["author_instance"][:8])}'
+ 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}'
@@ -180,15 +202,19 @@ class ForumHandlers:
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"
'
+ f'{search_form}'
+ f' + new'
+ f' mod'
+ f' {muted_link}'
+ f"
"
f"
{total} threads{new_label}
"
f"
{items}
"
- 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"
{esc(thread['body'])}
" if thread["body"] else ""
mute_btn = (
- f''
+ f'unmute'
if is_muted else
- f''
+ f'mute'
)
posts_html = ""
@@ -267,8 +296,10 @@ class ForumHandlers:
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(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'
'
- 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' ·
"
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:
@@ -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' · 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'
{esc(h[:16])}... '
+ f'
{label}{esc(h[:16])}...{reports} '
f'"
+ f"
peer reports
"
+ f"{self._peer_reports_html()}"
f"
keyword filters
"
f'"
f"
synced instances
"
f"{synced_items}"
f'"
f''
@@ -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)
diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py
index 32133b8..024aa2d 100644
--- a/tinyweb_forum/sync.py
+++ b/tinyweb_forum/sync.py
@@ -39,6 +39,8 @@ class ForumSync:
self._running = False
def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
+ if remote_identity:
+ data["peer_hash"] = remote_identity.hash.hex()
return self.handlers_ref().handle_sync(data)
def _sync_loop(self):
@@ -108,11 +110,20 @@ class ForumSync:
posts = [dict(r) for r in ps]
upvotes = [{"thread_id": tid, "instance_hash": instance_hash} for tid in uv]
+ my_blocks = [h.strip() for h in self.fdb.get_setting("blocked_instances", "").split(",") if h.strip()]
+ my_peer_blocks = self.fdb.get_peer_block_list()
+
+ retracted = [{"id": cid, "type": ct, "author": ai, "at": ra}
+ for cid, ct, ai, ra in self.fdb.get_raw_retractions()]
+
request_data = {
"query": {"since": [since]} if since else {},
"threads": threads,
"posts": posts,
"upvotes": upvotes,
+ "from_hash": self.identity.hash.hex() if self.identity else "local",
+ "blocks": {"mine": my_blocks, "peers": my_peer_blocks},
+ "retractions": retracted,
}
receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
@@ -129,12 +140,28 @@ class ForumSync:
data = json.loads(resp["body"])
except (json.JSONDecodeError, KeyError):
data = {}
+ my_blocks = set(h.strip() for h in self.fdb.get_setting("blocked_instances", "").split(",") if h.strip())
for t in data.get("threads", []):
- self.fdb.merge_thread(t)
+ if t.get("author_instance", "") not in my_blocks:
+ self.fdb.merge_thread(t)
for p in data.get("posts", []):
- self.fdb.merge_post(p)
+ if p.get("author_instance", "") not in my_blocks:
+ self.fdb.merge_post(p)
for tid in data.get("upvote_threads", []):
self.fdb.merge_upvote(tid, instance_hash)
+ # Gossip blocks from peer
+ peer_blocks = data.get("blocks", {})
+ for h in peer_blocks.get("mine", []):
+ if h and h not in my_blocks and instance_hash:
+ self.fdb.record_peer_block(instance_hash, h)
+ for h in peer_blocks.get("peers", []):
+ if h and h not in my_blocks and instance_hash:
+ self.fdb.record_peer_block(instance_hash, h)
+ self._apply_peer_blocks()
+ # 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"])
now = time.strftime("%Y-%m-%dT%H:%M:%S")
self.fdb.set_last_sync(instance_hash, now)
else:
@@ -142,5 +169,19 @@ class ForumSync:
finally:
link.teardown()
+ def _apply_peer_blocks(self):
+ counts = self.fdb.get_peer_block_counts()
+ blocked = set(h.strip() for h in self.fdb.get_setting("blocked_instances", "").split(",") if h.strip())
+ auto_blocked = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
+ changed = False
+ for h, count in counts.items():
+ if h not in blocked and h not in auto_blocked and count >= 3:
+ blocked.add(h)
+ auto_blocked.add(h)
+ changed = True
+ if changed:
+ self.fdb.set_setting("blocked_instances", ",".join(blocked))
+ self.fdb.set_setting("auto_blocked_instances", ",".join(auto_blocked))
+
def handle_sync(self, data):
return self.handlers_ref().handle_sync_request(data)
From 74dd108011b7f01fd6fb00c14ae237281216bb84 Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:34:16 +0000
Subject: [PATCH 05/58] add sync tests: thread, reply, upvote, block,
retraction, bidirectional, gossip, update
---
tests/test_sync.py | 346 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 346 insertions(+)
create mode 100644 tests/test_sync.py
diff --git a/tests/test_sync.py b/tests/test_sync.py
new file mode 100644
index 0000000..e1368b1
--- /dev/null
+++ b/tests/test_sync.py
@@ -0,0 +1,346 @@
+import json
+import os
+import tempfile
+import time
+import sys
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from tinyweb_forum.db import ForumDB
+from tinyweb_forum.handlers import ForumHandlers
+
+
+def make_handler(data_dir, site_name="test"):
+ fdb = ForumDB(data_dir)
+ handlers = ForumHandlers(fdb, None, None, None, site_name=site_name)
+ return fdb, handlers
+
+
+def test_sync_thread():
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Hello World", "https://example.com",
+ "First post", "test,hello",
+ "abc123", "", now)
+
+ # Simulate sync A -> B
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [],
+ "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ t = db_b.get_thread(thread_id)
+ assert t is not None, "thread should exist in B"
+ assert t["title"] == "Hello World"
+ assert t["url"] == "https://example.com"
+ assert t["tags"] == "test,hello"
+ assert t["author_instance"] == "abc123"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_reply():
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Thread", "", "Body", "",
+ "abc123", "", now)
+ post_id = "p1"
+ db_a.create_post(post_id, thread_id, "", "A reply", "def456", "", now)
+
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [dict(db_a.get_posts(thread_id)[0])],
+ "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ posts = db_b.get_posts(thread_id)
+ assert len(list(posts)) == 1
+ assert list(posts)[0]["body"] == "A reply"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_upvote():
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Thread", "", "", "",
+ "abc123", "", now)
+
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [],
+ "upvotes": [{"thread_id": thread_id, "instance_hash": "xyz789"}],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ t = db_b.get_thread(thread_id)
+ assert t is not None
+ assert t["score"] == 1
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_blocks_content():
+ """Threads from blocked authors should not sync."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ # Block the author
+ db_b.set_setting("blocked_instances", "abc123")
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Blocked Thread", "", "", "",
+ "abc123", "", now)
+
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [],
+ "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ t = db_b.get_thread(thread_id)
+ assert t is None, "blocked author's thread should not sync"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_retraction():
+ """Retraction signal from author should retract content on peer."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "To Retract", "", "", "",
+ "abc123", "", now)
+
+ # Sync thread to B first
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [], "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+ assert db_b.get_thread(thread_id) is not None
+
+ # Now retract in A and sync
+ later = "2026-06-05T13:00:00"
+ db_a.retract_thread(thread_id, "abc123", later)
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [], "posts": [], "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [{"id": thread_id, "type": "thread",
+ "author": "abc123", "at": later}],
+ })
+
+ t = db_b.get_thread(thread_id)
+ assert t is not None
+ assert t["title"] == "[retracted]"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_bidirectional():
+ """A and B sync each other's threads."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+
+ # A creates a thread
+ db_a.create_thread("t_a", "From A", "", "", "",
+ "aaa111", "", now)
+
+ # B creates a thread
+ db_b.create_thread("t_b", "From B", "", "", "",
+ "bbb222", "", now)
+
+ # A syncs B's content
+ ha.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_b.get_thread("t_b"))],
+ "posts": [], "upvotes": [],
+ "from_hash": "bbb222",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ # B syncs A's content
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread("t_a"))],
+ "posts": [], "upvotes": [],
+ "from_hash": "aaa111",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ assert db_a.get_thread("t_b") is not None, "A should have B's thread"
+ assert db_b.get_thread("t_a") is not None, "B should have A's thread"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_block_gossip():
+ """Peer blocks should be recorded and auto-block at threshold."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+
+ # A has blocked "malicious"
+ db_a.set_setting("blocked_instances", "malicious")
+
+ # A syncs to B — B should record A's blocks
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [], "posts": [], "upvotes": [],
+ "from_hash": "peer_a",
+ "blocks": {"mine": ["malicious"], "peers": []},
+ "retractions": [],
+ })
+
+ blocks_a = db_b.get_peer_block_list()
+ assert "malicious" in blocks_a
+
+ # Need 2 more peers to reach threshold 3
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [], "posts": [], "upvotes": [],
+ "from_hash": "peer_b",
+ "blocks": {"mine": ["malicious"], "peers": []},
+ "retractions": [],
+ })
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [], "posts": [], "upvotes": [],
+ "from_hash": "peer_c",
+ "blocks": {"mine": ["malicious"], "peers": []},
+ "retractions": [],
+ })
+
+ # _apply_peer_blocks would be called during sync loop
+ from tinyweb_forum.sync import ForumSync
+ # Mock just the _apply_peer_blocks logic
+ counts = db_b.get_peer_block_counts()
+ assert counts.get("malicious", 0) >= 3
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_updated_content():
+ """Updated thread should overwrite older version."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Old Title", "", "", "",
+ "abc123", "", now)
+
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [], "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+ assert db_b.get_thread(thread_id)["title"] == "Old Title"
+
+ # Update in A
+ later = "2026-06-05T14:00:00"
+ db_a.update_thread(thread_id, "New Title", "", "", "", later)
+
+ # Resync
+ hb.handle_sync_request({
+ "query": {"since": [now]},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [], "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+ assert db_b.get_thread(thread_id)["title"] == "New Title"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+if __name__ == "__main__":
+ test_sync_thread()
+ test_sync_reply()
+ test_sync_upvote()
+ test_sync_blocks_content()
+ test_sync_retraction()
+ test_sync_bidirectional()
+ test_sync_block_gossip()
+ test_sync_updated_content()
+ print("all sync tests passed")
From 062384dc0bfe5268fcbc579f5628cab9b5f4548d Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:34:16 +0000
Subject: [PATCH 06/58] add sync tests: thread, reply, upvote, block,
retraction, bidirectional, gossip, update
---
tests/test_sync.py | 346 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 346 insertions(+)
create mode 100644 tests/test_sync.py
diff --git a/tests/test_sync.py b/tests/test_sync.py
new file mode 100644
index 0000000..e1368b1
--- /dev/null
+++ b/tests/test_sync.py
@@ -0,0 +1,346 @@
+import json
+import os
+import tempfile
+import time
+import sys
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from tinyweb_forum.db import ForumDB
+from tinyweb_forum.handlers import ForumHandlers
+
+
+def make_handler(data_dir, site_name="test"):
+ fdb = ForumDB(data_dir)
+ handlers = ForumHandlers(fdb, None, None, None, site_name=site_name)
+ return fdb, handlers
+
+
+def test_sync_thread():
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Hello World", "https://example.com",
+ "First post", "test,hello",
+ "abc123", "", now)
+
+ # Simulate sync A -> B
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [],
+ "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ t = db_b.get_thread(thread_id)
+ assert t is not None, "thread should exist in B"
+ assert t["title"] == "Hello World"
+ assert t["url"] == "https://example.com"
+ assert t["tags"] == "test,hello"
+ assert t["author_instance"] == "abc123"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_reply():
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Thread", "", "Body", "",
+ "abc123", "", now)
+ post_id = "p1"
+ db_a.create_post(post_id, thread_id, "", "A reply", "def456", "", now)
+
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [dict(db_a.get_posts(thread_id)[0])],
+ "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ posts = db_b.get_posts(thread_id)
+ assert len(list(posts)) == 1
+ assert list(posts)[0]["body"] == "A reply"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_upvote():
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Thread", "", "", "",
+ "abc123", "", now)
+
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [],
+ "upvotes": [{"thread_id": thread_id, "instance_hash": "xyz789"}],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ t = db_b.get_thread(thread_id)
+ assert t is not None
+ assert t["score"] == 1
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_blocks_content():
+ """Threads from blocked authors should not sync."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ # Block the author
+ db_b.set_setting("blocked_instances", "abc123")
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Blocked Thread", "", "", "",
+ "abc123", "", now)
+
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [],
+ "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ t = db_b.get_thread(thread_id)
+ assert t is None, "blocked author's thread should not sync"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_retraction():
+ """Retraction signal from author should retract content on peer."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "To Retract", "", "", "",
+ "abc123", "", now)
+
+ # Sync thread to B first
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [], "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+ assert db_b.get_thread(thread_id) is not None
+
+ # Now retract in A and sync
+ later = "2026-06-05T13:00:00"
+ db_a.retract_thread(thread_id, "abc123", later)
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [], "posts": [], "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [{"id": thread_id, "type": "thread",
+ "author": "abc123", "at": later}],
+ })
+
+ t = db_b.get_thread(thread_id)
+ assert t is not None
+ assert t["title"] == "[retracted]"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_bidirectional():
+ """A and B sync each other's threads."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+
+ # A creates a thread
+ db_a.create_thread("t_a", "From A", "", "", "",
+ "aaa111", "", now)
+
+ # B creates a thread
+ db_b.create_thread("t_b", "From B", "", "", "",
+ "bbb222", "", now)
+
+ # A syncs B's content
+ ha.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_b.get_thread("t_b"))],
+ "posts": [], "upvotes": [],
+ "from_hash": "bbb222",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ # B syncs A's content
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread("t_a"))],
+ "posts": [], "upvotes": [],
+ "from_hash": "aaa111",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+
+ assert db_a.get_thread("t_b") is not None, "A should have B's thread"
+ assert db_b.get_thread("t_a") is not None, "B should have A's thread"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_block_gossip():
+ """Peer blocks should be recorded and auto-block at threshold."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+
+ # A has blocked "malicious"
+ db_a.set_setting("blocked_instances", "malicious")
+
+ # A syncs to B — B should record A's blocks
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [], "posts": [], "upvotes": [],
+ "from_hash": "peer_a",
+ "blocks": {"mine": ["malicious"], "peers": []},
+ "retractions": [],
+ })
+
+ blocks_a = db_b.get_peer_block_list()
+ assert "malicious" in blocks_a
+
+ # Need 2 more peers to reach threshold 3
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [], "posts": [], "upvotes": [],
+ "from_hash": "peer_b",
+ "blocks": {"mine": ["malicious"], "peers": []},
+ "retractions": [],
+ })
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [], "posts": [], "upvotes": [],
+ "from_hash": "peer_c",
+ "blocks": {"mine": ["malicious"], "peers": []},
+ "retractions": [],
+ })
+
+ # _apply_peer_blocks would be called during sync loop
+ from tinyweb_forum.sync import ForumSync
+ # Mock just the _apply_peer_blocks logic
+ counts = db_b.get_peer_block_counts()
+ assert counts.get("malicious", 0) >= 3
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+def test_sync_updated_content():
+ """Updated thread should overwrite older version."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+
+ now = "2026-06-05T12:00:00"
+ thread_id = "t1"
+ db_a.create_thread(thread_id, "Old Title", "", "", "",
+ "abc123", "", now)
+
+ hb.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [], "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+ assert db_b.get_thread(thread_id)["title"] == "Old Title"
+
+ # Update in A
+ later = "2026-06-05T14:00:00"
+ db_a.update_thread(thread_id, "New Title", "", "", "", later)
+
+ # Resync
+ hb.handle_sync_request({
+ "query": {"since": [now]},
+ "threads": [dict(db_a.get_thread(thread_id))],
+ "posts": [], "upvotes": [],
+ "from_hash": "abc123",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ })
+ assert db_b.get_thread(thread_id)["title"] == "New Title"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+
+
+if __name__ == "__main__":
+ test_sync_thread()
+ test_sync_reply()
+ test_sync_upvote()
+ test_sync_blocks_content()
+ test_sync_retraction()
+ test_sync_bidirectional()
+ test_sync_block_gossip()
+ test_sync_updated_content()
+ print("all sync tests passed")
From 0de6be8f3dc3808bcf12be4fe1ce44361a43eee9 Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:43:18 +0000
Subject: [PATCH 07/58] auto peer discovery: gossip known peers during sync,
discover from_hash automatically
---
tests/test_sync.py | 45 +++++++++++++++++++++++++++++++++++++++
tinyweb_forum/db.py | 22 +++++++++++++++++++
tinyweb_forum/handlers.py | 11 ++++++++++
tinyweb_forum/sync.py | 10 ++++++++-
4 files changed, 87 insertions(+), 1 deletion(-)
diff --git a/tests/test_sync.py b/tests/test_sync.py
index e1368b1..8e535cb 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -334,6 +334,50 @@ def test_sync_updated_content():
shutil.rmtree(dir_b)
+def test_sync_peer_discovery():
+ """Peers should discover each other through sync gossip."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ dir_c = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+ db_c, hc = make_handler(dir_c)
+
+ now = "2026-06-05T12:00:00"
+ db_a.create_thread("t1", "From A", "", "", "", "aaa", "", now)
+ db_b.create_thread("t2", "From B", "", "", "", "bbb", "", now)
+ db_c.create_thread("t3", "From C", "", "", "", "ccc", "", now)
+
+ # Seed: A knows B, B knows C
+ db_a.add_known_peer("bbb")
+ db_b.add_known_peer("ccc")
+ # C doesn't know anyone yet
+
+ # B syncs with C — B sends its known peers (ccc's hash not in B's known peers since B is talking to C)
+ # Actually, B knows C (bbb -> ccc), so when B sends sync request to C,
+ # B includes known_peers. C learns about B.
+ hc.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_b.get_thread("t2"))],
+ "posts": [], "upvotes": [],
+ "from_hash": "bbb",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ "known_peers": ["aaa"], # B tells C about A
+ })
+
+ # C should now know about B (from auto-discovery of from_hash) and A (from known_peers)
+ known = [r["instance_hash"] for r in db_c.get_synced_instances()]
+ assert "bbb" in known, "C should auto-discover B from from_hash"
+ assert "aaa" in known, "C should discover A from known_peers gossip"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+ shutil.rmtree(dir_c)
+
+
if __name__ == "__main__":
test_sync_thread()
test_sync_reply()
@@ -343,4 +387,5 @@ if __name__ == "__main__":
test_sync_bidirectional()
test_sync_block_gossip()
test_sync_updated_content()
+ test_sync_peer_discovery()
print("all sync tests passed")
diff --git a/tinyweb_forum/db.py b/tinyweb_forum/db.py
index 087d8e3..b81c8e7 100644
--- a/tinyweb_forum/db.py
+++ b/tinyweb_forum/db.py
@@ -246,6 +246,28 @@ class ForumDB:
finally:
self.return_db(db)
+ def add_known_peer(self, instance_hash):
+ """Add a discovered peer to the sync list (auto-discovery)."""
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR IGNORE INTO synced_instances (instance_hash) VALUES (?)",
+ (instance_hash,),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def get_all_known_hashes(self):
+ """Get all known instance hashes for peer discovery gossip."""
+ db = self.get_db()
+ try:
+ return [r["instance_hash"] for r in db.execute(
+ "SELECT instance_hash FROM synced_instances"
+ ).fetchall()]
+ finally:
+ self.return_db(db)
+
def upsert_synced_instance(self, instance_hash, name=""):
db = self.get_db()
try:
diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py
index 874ced7..8e45f9a 100644
--- a/tinyweb_forum/handlers.py
+++ b/tinyweb_forum/handlers.py
@@ -604,6 +604,14 @@ class ForumHandlers:
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 = [], [], []
@@ -616,6 +624,8 @@ class ForumHandlers:
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",
@@ -625,6 +635,7 @@ class ForumHandlers:
"upvote_threads": upvote_threads,
"blocks": {"mine": my_blocks, "peers": my_peer_blocks},
"retractions": retracted,
+ "known_peers": known_peers,
}),
"headers": {},
}
diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py
index 024aa2d..05e6f5c 100644
--- a/tinyweb_forum/sync.py
+++ b/tinyweb_forum/sync.py
@@ -116,14 +116,18 @@ class ForumSync:
retracted = [{"id": cid, "type": ct, "author": ai, "at": ra}
for cid, ct, ai, ra in self.fdb.get_raw_retractions()]
+ my_hash = self.identity.hash.hex() if self.identity else "local"
+ known_peers = [h for h in self.fdb.get_all_known_hashes() if h != instance_hash and h != my_hash]
+
request_data = {
"query": {"since": [since]} if since else {},
"threads": threads,
"posts": posts,
"upvotes": upvotes,
- "from_hash": self.identity.hash.hex() if self.identity else "local",
+ "from_hash": my_hash,
"blocks": {"mine": my_blocks, "peers": my_peer_blocks},
"retractions": retracted,
+ "known_peers": known_peers,
}
receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
@@ -162,6 +166,10 @@ class ForumSync:
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"])
+ # Discover new peers from gossip
+ for peer_hash in data.get("known_peers", []):
+ if peer_hash and peer_hash != my_hash and peer_hash != instance_hash:
+ self.fdb.add_known_peer(peer_hash)
now = time.strftime("%Y-%m-%dT%H:%M:%S")
self.fdb.set_last_sync(instance_hash, now)
else:
From 1bb490fb64649a46e2d3441d4ed167eacea8046a Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:43:18 +0000
Subject: [PATCH 08/58] auto peer discovery: gossip known peers during sync,
discover from_hash automatically
---
tests/test_sync.py | 45 +++++++++++++++++++++++++++++++++++++++
tinyweb_forum/db.py | 22 +++++++++++++++++++
tinyweb_forum/handlers.py | 11 ++++++++++
tinyweb_forum/sync.py | 10 ++++++++-
4 files changed, 87 insertions(+), 1 deletion(-)
diff --git a/tests/test_sync.py b/tests/test_sync.py
index e1368b1..8e535cb 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -334,6 +334,50 @@ def test_sync_updated_content():
shutil.rmtree(dir_b)
+def test_sync_peer_discovery():
+ """Peers should discover each other through sync gossip."""
+ dir_a = tempfile.mkdtemp()
+ dir_b = tempfile.mkdtemp()
+ dir_c = tempfile.mkdtemp()
+ try:
+ db_a, ha = make_handler(dir_a)
+ db_b, hb = make_handler(dir_b)
+ db_c, hc = make_handler(dir_c)
+
+ now = "2026-06-05T12:00:00"
+ db_a.create_thread("t1", "From A", "", "", "", "aaa", "", now)
+ db_b.create_thread("t2", "From B", "", "", "", "bbb", "", now)
+ db_c.create_thread("t3", "From C", "", "", "", "ccc", "", now)
+
+ # Seed: A knows B, B knows C
+ db_a.add_known_peer("bbb")
+ db_b.add_known_peer("ccc")
+ # C doesn't know anyone yet
+
+ # B syncs with C — B sends its known peers (ccc's hash not in B's known peers since B is talking to C)
+ # Actually, B knows C (bbb -> ccc), so when B sends sync request to C,
+ # B includes known_peers. C learns about B.
+ hc.handle_sync_request({
+ "query": {},
+ "threads": [dict(db_b.get_thread("t2"))],
+ "posts": [], "upvotes": [],
+ "from_hash": "bbb",
+ "blocks": {"mine": [], "peers": []},
+ "retractions": [],
+ "known_peers": ["aaa"], # B tells C about A
+ })
+
+ # C should now know about B (from auto-discovery of from_hash) and A (from known_peers)
+ known = [r["instance_hash"] for r in db_c.get_synced_instances()]
+ assert "bbb" in known, "C should auto-discover B from from_hash"
+ assert "aaa" in known, "C should discover A from known_peers gossip"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+ shutil.rmtree(dir_b)
+ shutil.rmtree(dir_c)
+
+
if __name__ == "__main__":
test_sync_thread()
test_sync_reply()
@@ -343,4 +387,5 @@ if __name__ == "__main__":
test_sync_bidirectional()
test_sync_block_gossip()
test_sync_updated_content()
+ test_sync_peer_discovery()
print("all sync tests passed")
diff --git a/tinyweb_forum/db.py b/tinyweb_forum/db.py
index 087d8e3..b81c8e7 100644
--- a/tinyweb_forum/db.py
+++ b/tinyweb_forum/db.py
@@ -246,6 +246,28 @@ class ForumDB:
finally:
self.return_db(db)
+ def add_known_peer(self, instance_hash):
+ """Add a discovered peer to the sync list (auto-discovery)."""
+ db = self.get_db()
+ try:
+ db.execute(
+ "INSERT OR IGNORE INTO synced_instances (instance_hash) VALUES (?)",
+ (instance_hash,),
+ )
+ db.commit()
+ finally:
+ self.return_db(db)
+
+ def get_all_known_hashes(self):
+ """Get all known instance hashes for peer discovery gossip."""
+ db = self.get_db()
+ try:
+ return [r["instance_hash"] for r in db.execute(
+ "SELECT instance_hash FROM synced_instances"
+ ).fetchall()]
+ finally:
+ self.return_db(db)
+
def upsert_synced_instance(self, instance_hash, name=""):
db = self.get_db()
try:
diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py
index 874ced7..8e45f9a 100644
--- a/tinyweb_forum/handlers.py
+++ b/tinyweb_forum/handlers.py
@@ -604,6 +604,14 @@ class ForumHandlers:
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 = [], [], []
@@ -616,6 +624,8 @@ class ForumHandlers:
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",
@@ -625,6 +635,7 @@ class ForumHandlers:
"upvote_threads": upvote_threads,
"blocks": {"mine": my_blocks, "peers": my_peer_blocks},
"retractions": retracted,
+ "known_peers": known_peers,
}),
"headers": {},
}
diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py
index 024aa2d..05e6f5c 100644
--- a/tinyweb_forum/sync.py
+++ b/tinyweb_forum/sync.py
@@ -116,14 +116,18 @@ class ForumSync:
retracted = [{"id": cid, "type": ct, "author": ai, "at": ra}
for cid, ct, ai, ra in self.fdb.get_raw_retractions()]
+ my_hash = self.identity.hash.hex() if self.identity else "local"
+ known_peers = [h for h in self.fdb.get_all_known_hashes() if h != instance_hash and h != my_hash]
+
request_data = {
"query": {"since": [since]} if since else {},
"threads": threads,
"posts": posts,
"upvotes": upvotes,
- "from_hash": self.identity.hash.hex() if self.identity else "local",
+ "from_hash": my_hash,
"blocks": {"mine": my_blocks, "peers": my_peer_blocks},
"retractions": retracted,
+ "known_peers": known_peers,
}
receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
@@ -162,6 +166,10 @@ class ForumSync:
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"])
+ # Discover new peers from gossip
+ for peer_hash in data.get("known_peers", []):
+ if peer_hash and peer_hash != my_hash and peer_hash != instance_hash:
+ self.fdb.add_known_peer(peer_hash)
now = time.strftime("%Y-%m-%dT%H:%M:%S")
self.fdb.set_last_sync(instance_hash, now)
else:
From 6a1f47818df536c26d60738813c1eff2925f2c00 Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:55:03 +0000
Subject: [PATCH 09/58] truly public forum: auto-discover other instances via
RNS announce handler
---
tests/test_sync.py | 52 +++++++++++++++++++++++++++++++++++++++++++
tinyweb_forum/sync.py | 29 +++++++++++++++++++++++-
2 files changed, 80 insertions(+), 1 deletion(-)
diff --git a/tests/test_sync.py b/tests/test_sync.py
index 8e535cb..6ad6833 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -378,6 +378,57 @@ def test_sync_peer_discovery():
shutil.rmtree(dir_c)
+def test_announce_handler():
+ """Announce handler should auto-discover peers."""
+ dir_a = tempfile.mkdtemp()
+ try:
+ fdb = ForumDB(dir_a)
+ from tinyweb_forum.sync import _ForumAnnounceHandler
+
+ # Mock identity — RNS identity.hash returns bytes
+ class MockIdentity:
+ def __init__(self, h):
+ self._hash_hex = h
+ @property
+ def hash(self):
+ return bytes.fromhex(self._hash_hex)
+
+ local_hex = "aa" * 16
+ peer_hex = "bb" * 16
+
+ handler = _ForumAnnounceHandler(fdb, MockIdentity(local_hex))
+
+ class MockAnnouncedIdentity:
+ def __init__(self, h):
+ self._hash_hex = h
+ @property
+ def hash(self):
+ return bytes.fromhex(self._hash_hex)
+
+ # Announce from a peer
+ handler.received_announce(
+ destination_hash=bytes.fromhex(peer_hex),
+ announced_identity=MockAnnouncedIdentity(peer_hex),
+ app_data=b"tinyweb-forum",
+ )
+
+ known = [r["instance_hash"] for r in fdb.get_synced_instances()]
+ assert peer_hex in known, "peer should be added to sync list"
+
+ # Announce from ourselves should be ignored
+ handler.received_announce(
+ destination_hash=bytes.fromhex(local_hex),
+ announced_identity=MockAnnouncedIdentity(local_hex),
+ app_data=b"tinyweb-forum",
+ )
+
+ known = [r["instance_hash"] for r in fdb.get_synced_instances()]
+ assert known.count(local_hex) == 0, "own announce should be ignored"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+
+
if __name__ == "__main__":
test_sync_thread()
test_sync_reply()
@@ -388,4 +439,5 @@ if __name__ == "__main__":
test_sync_block_gossip()
test_sync_updated_content()
test_sync_peer_discovery()
+ test_announce_handler()
print("all sync tests passed")
diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py
index 05e6f5c..098dfc0 100644
--- a/tinyweb_forum/sync.py
+++ b/tinyweb_forum/sync.py
@@ -8,6 +8,24 @@ SYNC_INTERVAL = 300 # 5 minutes
REQUEST_TIMEOUT = 60
+class _ForumAnnounceHandler:
+ """Receives announces from other forum instances and auto-discovers them."""
+
+ aspect_filter = FORUM_APP
+ receive_path_responses = False
+
+ def __init__(self, fdb, identity):
+ self.fdb = fdb
+ self.my_hash = identity.hash.hex() if identity else "local"
+
+ def received_announce(self, destination_hash, announced_identity, app_data):
+ if announced_identity is None:
+ return
+ peer_hash = announced_identity.hash.hex()
+ if peer_hash and peer_hash != self.my_hash:
+ self.fdb.add_known_peer(peer_hash)
+
+
class ForumSync:
def __init__(self, fdb, identity, reticulum, handlers_ref):
self.fdb = fdb
@@ -15,6 +33,7 @@ class ForumSync:
self.reticulum = reticulum
self.handlers_ref = handlers_ref
self.destination = None
+ self._announce_handler = None
self._running = False
self._thread = None
@@ -30,13 +49,21 @@ class ForumSync:
response_generator=self._rns_handler,
allow=RNS.Destination.ALLOW_ALL,
)
- self.destination.announce()
+ self.destination.announce(app_data=FORUM_APP.encode("utf-8"))
+ # Auto-discover other forum instances via announces
+ self._announce_handler = _ForumAnnounceHandler(self.fdb, self.identity)
+ RNS.Transport.register_announce_handler(self._announce_handler)
self._running = True
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
self._thread.start()
def stop(self):
self._running = False
+ if self._announce_handler:
+ try:
+ RNS.Transport.deregister_announce_handler(self._announce_handler)
+ except Exception:
+ pass
def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
if remote_identity:
From ea3591ba481020d7554285d6b2733d85bde997eb Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:55:03 +0000
Subject: [PATCH 10/58] truly public forum: auto-discover other instances via
RNS announce handler
---
tests/test_sync.py | 52 +++++++++++++++++++++++++++++++++++++++++++
tinyweb_forum/sync.py | 29 +++++++++++++++++++++++-
2 files changed, 80 insertions(+), 1 deletion(-)
diff --git a/tests/test_sync.py b/tests/test_sync.py
index 8e535cb..6ad6833 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -378,6 +378,57 @@ def test_sync_peer_discovery():
shutil.rmtree(dir_c)
+def test_announce_handler():
+ """Announce handler should auto-discover peers."""
+ dir_a = tempfile.mkdtemp()
+ try:
+ fdb = ForumDB(dir_a)
+ from tinyweb_forum.sync import _ForumAnnounceHandler
+
+ # Mock identity — RNS identity.hash returns bytes
+ class MockIdentity:
+ def __init__(self, h):
+ self._hash_hex = h
+ @property
+ def hash(self):
+ return bytes.fromhex(self._hash_hex)
+
+ local_hex = "aa" * 16
+ peer_hex = "bb" * 16
+
+ handler = _ForumAnnounceHandler(fdb, MockIdentity(local_hex))
+
+ class MockAnnouncedIdentity:
+ def __init__(self, h):
+ self._hash_hex = h
+ @property
+ def hash(self):
+ return bytes.fromhex(self._hash_hex)
+
+ # Announce from a peer
+ handler.received_announce(
+ destination_hash=bytes.fromhex(peer_hex),
+ announced_identity=MockAnnouncedIdentity(peer_hex),
+ app_data=b"tinyweb-forum",
+ )
+
+ known = [r["instance_hash"] for r in fdb.get_synced_instances()]
+ assert peer_hex in known, "peer should be added to sync list"
+
+ # Announce from ourselves should be ignored
+ handler.received_announce(
+ destination_hash=bytes.fromhex(local_hex),
+ announced_identity=MockAnnouncedIdentity(local_hex),
+ app_data=b"tinyweb-forum",
+ )
+
+ known = [r["instance_hash"] for r in fdb.get_synced_instances()]
+ assert known.count(local_hex) == 0, "own announce should be ignored"
+ finally:
+ import shutil
+ shutil.rmtree(dir_a)
+
+
if __name__ == "__main__":
test_sync_thread()
test_sync_reply()
@@ -388,4 +439,5 @@ if __name__ == "__main__":
test_sync_block_gossip()
test_sync_updated_content()
test_sync_peer_discovery()
+ test_announce_handler()
print("all sync tests passed")
diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py
index 05e6f5c..098dfc0 100644
--- a/tinyweb_forum/sync.py
+++ b/tinyweb_forum/sync.py
@@ -8,6 +8,24 @@ SYNC_INTERVAL = 300 # 5 minutes
REQUEST_TIMEOUT = 60
+class _ForumAnnounceHandler:
+ """Receives announces from other forum instances and auto-discovers them."""
+
+ aspect_filter = FORUM_APP
+ receive_path_responses = False
+
+ def __init__(self, fdb, identity):
+ self.fdb = fdb
+ self.my_hash = identity.hash.hex() if identity else "local"
+
+ def received_announce(self, destination_hash, announced_identity, app_data):
+ if announced_identity is None:
+ return
+ peer_hash = announced_identity.hash.hex()
+ if peer_hash and peer_hash != self.my_hash:
+ self.fdb.add_known_peer(peer_hash)
+
+
class ForumSync:
def __init__(self, fdb, identity, reticulum, handlers_ref):
self.fdb = fdb
@@ -15,6 +33,7 @@ class ForumSync:
self.reticulum = reticulum
self.handlers_ref = handlers_ref
self.destination = None
+ self._announce_handler = None
self._running = False
self._thread = None
@@ -30,13 +49,21 @@ class ForumSync:
response_generator=self._rns_handler,
allow=RNS.Destination.ALLOW_ALL,
)
- self.destination.announce()
+ self.destination.announce(app_data=FORUM_APP.encode("utf-8"))
+ # Auto-discover other forum instances via announces
+ self._announce_handler = _ForumAnnounceHandler(self.fdb, self.identity)
+ RNS.Transport.register_announce_handler(self._announce_handler)
self._running = True
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
self._thread.start()
def stop(self):
self._running = False
+ if self._announce_handler:
+ try:
+ RNS.Transport.deregister_announce_handler(self._announce_handler)
+ except Exception:
+ pass
def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
if remote_identity:
From 68a9b569d8a7307deaceb571edc3dfe1201ac8dd Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 00:55:23 +0000
Subject: [PATCH 11/58] update moderation page: note auto-discovery, bootstrap
fallback
---
tinyweb_forum/handlers.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py
index 8e45f9a..72c083f 100644
--- a/tinyweb_forum/handlers.py
+++ b/tinyweb_forum/handlers.py
@@ -487,6 +487,7 @@ class ForumHandlers:
return self._respond(
f"
forum moderation
"
f"
{msg}
"
+ f"
Forum instances on the mesh are discovered and synced automatically.
"
f"
blocked instances
"
f"{blocked_items}"
f'"
f"
synced instances
"
f"{synced_items}"
+ f"
Instances are discovered automatically via mesh announces. "
+ f"You can also manually add a friend's instance hash to bootstrap.