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"

    forum{tag_label}

    " - f"

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

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

    {total} threads{new_label}

    " f"" - f"{self._page_nav(page, total, f'/forum?q={esc(search)}&tag={esc(tag)}' if search or tag else '/forum')}" + 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'{self._csrf_field()}
    ' + f'unmute' if is_muted else - f'
    ' - f'{self._csrf_field()}
    ' + 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'

    {esc(p["body"])}

    ' f'{save_links}' f'
    ' @@ -277,7 +308,7 @@ class ForumHandlers: reply_form = ( f'
    ' f'{self._csrf_field()}' - f'
    ' + f'

    ' f'' f"
    " ) @@ -285,23 +316,83 @@ class ForumHandlers: return self._respond( f"

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

    " 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'{self._csrf_field()}
    ' + f' · {"-1" if has_upvoted else "+1"}' + f'{self._author_links(thread["id"], thread["author_instance"], instance_hash)}' f'

    ' f'{url_html}' f'{body_html}' f'{tags_html}' f"
    " - f"{posts_html}" - f"
    " - f"{reply_form}" + f"{posts_html}

    " + f"{reply_form}

    " 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'{self._csrf_field()}' + f'

    ' + f'

    ' + f'

    ' + f'

    ' + f'' + 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"" + 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'{self._csrf_field()}' f'' @@ -374,21 +491,23 @@ class ForumHandlers: f"{blocked_items}" f'' f'{self._csrf_field()}' - f' ' + f'

    ' f'' f"
    " + f"

    peer reports

    " + f"{self._peer_reports_html()}" f"

    keyword filters

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

    ' f'' f"
    " f"

    synced instances

    " f"{synced_items}" f'
    ' f'{self._csrf_field()}' - f' ' - f' ' + f'

    ' + f'

    ' f'' 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)