From a2d029097d1b3126667b83cbf5bf1ba4a5e17dd0 Mon Sep 17 00:00:00 2001
From: lichenblankie
Date: Fri, 5 Jun 2026 00:32:30 +0000
Subject: [PATCH] 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..2f7c382 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/lichenblankie/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/lichenblankie/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' · {"-1" if has_upvoted else "+1"}'
+ f'{self._author_links(thread["id"], thread["author_instance"], instance_hash)}'
f'
"
f'back to forum'
)
+ def handle_retract_thread(self, thread_id):
+ thread = self.fdb.get_thread(thread_id)
+ if not thread:
+ return self._error(404)
+ instance_hash = self.identity.hash.hex() if self.identity else "local"
+ if thread["author_instance"] != instance_hash:
+ return self._error(403)
+ self.fdb.retract_thread(thread_id, instance_hash, self._now())
+ return self._redirect("/forum")
+
+ def handle_retract_post(self, post_id, thread_id):
+ fdb = self.fdb
+ posts = fdb.get_posts(thread_id)
+ post = next((p for p in posts if p["id"] == post_id), None)
+ if not post:
+ return self._error(404)
+ instance_hash = self.identity.hash.hex() if self.identity else "local"
+ if post["author_instance"] != instance_hash:
+ return self._error(403)
+ fdb.retract_post(post_id, instance_hash, self._now())
+ return self._redirect(f"/forum/t/{thread_id}")
+
+ def handle_edit_form(self, thread_id, msg=""):
+ thread = self.fdb.get_thread(thread_id)
+ if not thread:
+ return self._error(404)
+ instance_hash = self.identity.hash.hex() if self.identity else "local"
+ if thread["author_instance"] != instance_hash:
+ return self._error(403)
+ return self._respond(
+ f"
edit thread
"
+ f'"
+ f"
{msg}
"
+ f'back'
+ )
+
+ def handle_edit_submit(self, thread_id, body):
+ thread = self.fdb.get_thread(thread_id)
+ if not thread:
+ return self._error(404)
+ instance_hash = self.identity.hash.hex() if self.identity else "local"
+ if thread["author_instance"] != instance_hash:
+ return self._error(403)
+ title = body.get("title", [""])[0].strip()
+ if not title:
+ return self.handle_edit_form(thread_id, "Title is required.")
+ url = body.get("url", [""])[0].strip()
+ body_text = body.get("body", [""])[0].strip()
+ tags = body.get("tags", [""])[0].strip()
+ now = self._now()
+ self.fdb.update_thread(thread_id, title, url, body_text, tags, now)
+ return self._redirect(f"/forum/t/{thread_id}")
+
def handle_reply(self, thread_id, body):
body_text = body.get("body", [""])[0].strip()
if not body_text:
@@ -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)