forum trust circle: trust-gated content exchange via subscription graph
This commit is contained in:
parent
6f700c213f
commit
3a26a726a4
4 changed files with 219 additions and 15 deletions
|
|
@ -1,8 +1,14 @@
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from tinyweb_forum.db import ForumDB
|
from tinyweb_forum.db import ForumDB
|
||||||
from tinyweb_forum.handlers import ForumHandlers
|
from tinyweb_forum.handlers import ForumHandlers
|
||||||
from tinyweb_forum.sync import ForumSync
|
from tinyweb_forum.sync import ForumSync
|
||||||
|
|
||||||
FORUM_ENABLED_KEY = "forum_enabled"
|
FORUM_ENABLED_KEY = "forum_enabled"
|
||||||
|
TRUST_REFRESH_INTERVAL = 120
|
||||||
|
|
||||||
|
|
||||||
class ForumPlugin:
|
class ForumPlugin:
|
||||||
|
|
@ -16,19 +22,52 @@ class ForumPlugin:
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
self.reticulum = reticulum
|
self.reticulum = reticulum
|
||||||
self._started = False
|
self._started = False
|
||||||
|
self._data_dir = data_dir
|
||||||
|
self._core_db_path = os.path.join(data_dir, "index.db")
|
||||||
|
self._trust_refresh_thread = None
|
||||||
|
|
||||||
def is_enabled(self):
|
def is_enabled(self):
|
||||||
return self.fdb.get_setting(FORUM_ENABLED_KEY, "0") == "1"
|
return self.fdb.get_setting(FORUM_ENABLED_KEY, "0") == "1"
|
||||||
|
|
||||||
|
def _seed_trust_from_subscriptions(self):
|
||||||
|
my_hash = self.identity.hash.hex() if self.identity else "local"
|
||||||
|
if not os.path.exists(self._core_db_path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
core = sqlite3.connect(self._core_db_path)
|
||||||
|
rows = core.execute(
|
||||||
|
"SELECT dest_hash FROM subscriptions WHERE forum_enabled = 1"
|
||||||
|
).fetchall()
|
||||||
|
core.close()
|
||||||
|
for row in rows:
|
||||||
|
self.fdb.add_trust_source(row[0], my_hash, 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _trust_refresh_loop(self):
|
||||||
|
while self._started:
|
||||||
|
try:
|
||||||
|
self._seed_trust_from_subscriptions()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for _ in range(TRUST_REFRESH_INTERVAL):
|
||||||
|
if not self._started:
|
||||||
|
return
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
def enable(self):
|
def enable(self):
|
||||||
self.fdb.set_setting(FORUM_ENABLED_KEY, "1")
|
self.fdb.set_setting(FORUM_ENABLED_KEY, "1")
|
||||||
if not self._started:
|
if not self._started:
|
||||||
|
self._seed_trust_from_subscriptions()
|
||||||
self.sync.start()
|
self.sync.start()
|
||||||
self._started = True
|
self._started = True
|
||||||
|
self._trust_refresh_thread = threading.Thread(
|
||||||
|
target=self._trust_refresh_loop, daemon=True
|
||||||
|
)
|
||||||
|
self._trust_refresh_thread.start()
|
||||||
|
|
||||||
def disable(self):
|
def disable(self):
|
||||||
self.fdb.set_setting(FORUM_ENABLED_KEY, "0")
|
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):
|
def handle(self, method, path, query, body, cookies=None):
|
||||||
return self.handlers.handle(method, path, query, body, cookies)
|
return self.handlers.handle(method, path, query, body, cookies)
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,20 @@ class ForumDB:
|
||||||
db.execute("ALTER TABLE synced_instances ADD COLUMN consecutive_failures INTEGER DEFAULT 0")
|
db.execute("ALTER TABLE synced_instances ADD COLUMN consecutive_failures INTEGER DEFAULT 0")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
db.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS forum_trust_sources ("
|
||||||
|
" instance_hash TEXT NOT NULL,"
|
||||||
|
" source_hash TEXT NOT NULL,"
|
||||||
|
" hops INTEGER NOT NULL,"
|
||||||
|
" PRIMARY KEY (instance_hash, source_hash)"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS forum_trust ("
|
||||||
|
" instance_hash TEXT PRIMARY KEY,"
|
||||||
|
" min_hops INTEGER NOT NULL"
|
||||||
|
")"
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -676,6 +690,106 @@ class ForumDB:
|
||||||
finally:
|
finally:
|
||||||
self.return_db(db)
|
self.return_db(db)
|
||||||
|
|
||||||
|
# --- Forum Trust ---
|
||||||
|
|
||||||
|
def add_trust_source(self, instance_hash, source_hash, hops):
|
||||||
|
db = self.get_db()
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR IGNORE INTO forum_trust_sources "
|
||||||
|
"(instance_hash, source_hash, hops) VALUES (?, ?, ?)",
|
||||||
|
(instance_hash, source_hash, hops),
|
||||||
|
)
|
||||||
|
existing = db.execute(
|
||||||
|
"SELECT hops FROM forum_trust_sources "
|
||||||
|
"WHERE instance_hash=? AND source_hash=?",
|
||||||
|
(instance_hash, source_hash),
|
||||||
|
).fetchone()
|
||||||
|
if existing and hops < existing["hops"]:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE forum_trust_sources SET hops=? "
|
||||||
|
"WHERE instance_hash=? AND source_hash=?",
|
||||||
|
(hops, instance_hash, source_hash),
|
||||||
|
)
|
||||||
|
self._recompute_trust_view(db)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
self.return_db(db)
|
||||||
|
|
||||||
|
def remove_trust_source(self, source_hash):
|
||||||
|
db = self.get_db()
|
||||||
|
try:
|
||||||
|
rows = db.execute(
|
||||||
|
"WITH RECURSIVE cascade(h) AS ("
|
||||||
|
" VALUES(?)"
|
||||||
|
" UNION ALL"
|
||||||
|
" SELECT fts.instance_hash FROM forum_trust_sources fts"
|
||||||
|
" INNER JOIN cascade c ON fts.source_hash = c.h"
|
||||||
|
") SELECT h FROM cascade",
|
||||||
|
(source_hash,),
|
||||||
|
).fetchall()
|
||||||
|
cascade_hashes = [r["h"] for r in rows]
|
||||||
|
placeholders = ",".join("?" for _ in cascade_hashes)
|
||||||
|
db.execute(
|
||||||
|
f"DELETE FROM forum_trust_sources WHERE source_hash IN ({placeholders})",
|
||||||
|
cascade_hashes,
|
||||||
|
)
|
||||||
|
self._recompute_trust_view(db)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
self.return_db(db)
|
||||||
|
|
||||||
|
def remove_by_hash(self, instance_hash):
|
||||||
|
self.remove_trust_source(instance_hash)
|
||||||
|
db = self.get_db()
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM forum_trust_sources WHERE instance_hash=?",
|
||||||
|
(instance_hash,),
|
||||||
|
)
|
||||||
|
self._recompute_trust_view(db)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
self.return_db(db)
|
||||||
|
|
||||||
|
def is_trusted(self, instance_hash):
|
||||||
|
db = self.get_db()
|
||||||
|
try:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT 1 FROM forum_trust WHERE instance_hash=?", (instance_hash,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
finally:
|
||||||
|
self.return_db(db)
|
||||||
|
|
||||||
|
def get_trust_hops(self, instance_hash):
|
||||||
|
db = self.get_db()
|
||||||
|
try:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT min_hops FROM forum_trust WHERE instance_hash=?", (instance_hash,)
|
||||||
|
).fetchone()
|
||||||
|
return row["min_hops"] if row else None
|
||||||
|
finally:
|
||||||
|
self.return_db(db)
|
||||||
|
|
||||||
|
def get_all_trusted_hashes(self):
|
||||||
|
db = self.get_db()
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
r["instance_hash"] for r in db.execute(
|
||||||
|
"SELECT instance_hash FROM forum_trust"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
self.return_db(db)
|
||||||
|
|
||||||
|
def _recompute_trust_view(self, db):
|
||||||
|
db.execute("DELETE FROM forum_trust")
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO forum_trust (instance_hash, min_hops) "
|
||||||
|
"SELECT instance_hash, MIN(hops) FROM forum_trust_sources GROUP BY instance_hash"
|
||||||
|
)
|
||||||
|
|
||||||
# --- Peer Liveness ---
|
# --- Peer Liveness ---
|
||||||
|
|
||||||
def record_sync_result(self, peer_hash, success):
|
def record_sync_result(self, peer_hash, success):
|
||||||
|
|
|
||||||
|
|
@ -721,6 +721,7 @@ class ForumHandlers:
|
||||||
blocked = self._blocked_instances()
|
blocked = self._blocked_instances()
|
||||||
blocked.add(instance)
|
blocked.add(instance)
|
||||||
self.fdb.set_setting("blocked_instances", ",".join(blocked))
|
self.fdb.set_setting("blocked_instances", ",".join(blocked))
|
||||||
|
self.fdb.remove_by_hash(instance)
|
||||||
self._set_flash(f"Blocked {instance[:16]}...")
|
self._set_flash(f"Blocked {instance[:16]}...")
|
||||||
return self._redirect("/forum/moderation")
|
return self._redirect("/forum/moderation")
|
||||||
|
|
||||||
|
|
@ -744,6 +745,7 @@ class ForumHandlers:
|
||||||
self.fdb.clear_peer_block(instance)
|
self.fdb.clear_peer_block(instance)
|
||||||
else:
|
else:
|
||||||
blocked.add(instance)
|
blocked.add(instance)
|
||||||
|
self.fdb.remove_by_hash(instance)
|
||||||
self.fdb.set_setting("blocked_instances", ",".join(blocked))
|
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 = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
|
||||||
auto.discard(instance)
|
auto.discard(instance)
|
||||||
|
|
@ -879,6 +881,10 @@ class ForumHandlers:
|
||||||
if scoped_query_tag and from_hash:
|
if scoped_query_tag and from_hash:
|
||||||
scoped_query_results = self.fdb.get_filtered_peers_by_tag(scoped_query_tag)
|
scoped_query_results = self.fdb.get_filtered_peers_by_tag(scoped_query_tag)
|
||||||
|
|
||||||
|
# Merge trust gossip from requester
|
||||||
|
if from_hash:
|
||||||
|
self.sync._merge_trust_gossip(data.get("trust_list", []), from_hash)
|
||||||
|
|
||||||
# Store peer's topics for future routing (backward compat)
|
# Store peer's topics for future routing (backward compat)
|
||||||
if peer_topics and from_hash:
|
if peer_topics and from_hash:
|
||||||
self.fdb.set_setting(f"peer_topics_{from_hash}", ",".join(peer_topics))
|
self.fdb.set_setting(f"peer_topics_{from_hash}", ",".join(peer_topics))
|
||||||
|
|
@ -920,7 +926,10 @@ class ForumHandlers:
|
||||||
# Merge incoming content, filtered by bloom and topics
|
# Merge incoming content, filtered by bloom and topics
|
||||||
if incoming_threads:
|
if incoming_threads:
|
||||||
for t in incoming_threads:
|
for t in incoming_threads:
|
||||||
if t.get("author_instance", "") in blocked:
|
author = t.get("author_instance", "")
|
||||||
|
if author in blocked:
|
||||||
|
continue
|
||||||
|
if author and not self.fdb.is_trusted(author):
|
||||||
continue
|
continue
|
||||||
if peer_bloom and peer_bloom.might_contain(t["id"]):
|
if peer_bloom and peer_bloom.might_contain(t["id"]):
|
||||||
continue
|
continue
|
||||||
|
|
@ -932,7 +941,10 @@ class ForumHandlers:
|
||||||
self.fdb.merge_thread(t)
|
self.fdb.merge_thread(t)
|
||||||
if incoming_posts:
|
if incoming_posts:
|
||||||
for p in incoming_posts:
|
for p in incoming_posts:
|
||||||
if p.get("author_instance", "") in blocked:
|
author = p.get("author_instance", "")
|
||||||
|
if author in blocked:
|
||||||
|
continue
|
||||||
|
if author and not self.fdb.is_trusted(author):
|
||||||
continue
|
continue
|
||||||
if peer_bloom and peer_bloom.might_contain(p["id"]):
|
if peer_bloom and peer_bloom.might_contain(p["id"]):
|
||||||
continue
|
continue
|
||||||
|
|
@ -981,6 +993,13 @@ class ForumHandlers:
|
||||||
posts = [dict(r) for r in posts_list]
|
posts = [dict(r) for r in posts_list]
|
||||||
upvote_threads = up_list
|
upvote_threads = up_list
|
||||||
|
|
||||||
|
# Filter outgoing content by requester's trust list
|
||||||
|
requester_trust = set(data.get("trust_list", []))
|
||||||
|
if requester_trust:
|
||||||
|
threads = [t for t in threads if t.get("author_instance", "") in requester_trust]
|
||||||
|
thread_ids = set(t["id"] for t in threads)
|
||||||
|
posts = [p for p in posts if p.get("thread_id") in thread_ids]
|
||||||
|
|
||||||
known_peers = [h for h in self.fdb.get_all_known_hashes() if h != from_hash]
|
known_peers = [h for h in self.fdb.get_all_known_hashes() if h != from_hash]
|
||||||
peer_topics_map = {}
|
peer_topics_map = {}
|
||||||
for ph in known_peers[:100]:
|
for ph in known_peers[:100]:
|
||||||
|
|
@ -1012,6 +1031,7 @@ class ForumHandlers:
|
||||||
"tag_bloom_size": peer_tag_bs,
|
"tag_bloom_size": peer_tag_bs,
|
||||||
"tag_bloom_hashes": peer_tag_bh,
|
"tag_bloom_hashes": peer_tag_bh,
|
||||||
"filter_table": filter_table_gossip,
|
"filter_table": filter_table_gossip,
|
||||||
|
"trust_list": self.fdb.get_all_trusted_hashes(),
|
||||||
"scoped_query_results": scoped_query_results,
|
"scoped_query_results": scoped_query_results,
|
||||||
}),
|
}),
|
||||||
"headers": {},
|
"headers": {},
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,24 @@ class ForumSync:
|
||||||
return True
|
return True
|
||||||
return any(peer_bloom.might_contain(t) for t in my_topics)
|
return any(peer_bloom.might_contain(t) for t in my_topics)
|
||||||
|
|
||||||
|
def _merge_trust_gossip(self, trust_list, source_hash):
|
||||||
|
source_hops = self.fdb.get_trust_hops(source_hash)
|
||||||
|
if source_hops is None:
|
||||||
|
return
|
||||||
|
my_hash = self.identity.hash.hex() if self.identity else "local"
|
||||||
|
blocked = set(
|
||||||
|
h.strip()
|
||||||
|
for h in self.fdb.get_setting("blocked_instances", "").split(",")
|
||||||
|
if h.strip()
|
||||||
|
)
|
||||||
|
for peer_hash in trust_list:
|
||||||
|
if peer_hash in blocked or peer_hash == my_hash:
|
||||||
|
continue
|
||||||
|
new_hops = source_hops + 1
|
||||||
|
existing = self.fdb.get_trust_hops(peer_hash)
|
||||||
|
if existing is None or new_hops < existing:
|
||||||
|
self.fdb.add_trust_source(peer_hash, source_hash, new_hops)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self.destination = RNS.Destination(
|
self.destination = RNS.Destination(
|
||||||
self.identity,
|
self.identity,
|
||||||
|
|
@ -415,6 +433,7 @@ class ForumSync:
|
||||||
"tag_bloom_size": TAG_BLOOM_SIZE,
|
"tag_bloom_size": TAG_BLOOM_SIZE,
|
||||||
"tag_bloom_hashes": TAG_BLOOM_HASHES,
|
"tag_bloom_hashes": TAG_BLOOM_HASHES,
|
||||||
"filter_table": filter_table_gossip,
|
"filter_table": filter_table_gossip,
|
||||||
|
"trust_list": self.fdb.get_all_trusted_hashes(),
|
||||||
}
|
}
|
||||||
|
|
||||||
receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
|
receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
@ -479,6 +498,9 @@ class ForumSync:
|
||||||
self.fdb.record_sync_result(instance_hash, True)
|
self.fdb.record_sync_result(instance_hash, True)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Merge trust gossip from peer
|
||||||
|
self._merge_trust_gossip(data.get("trust_list", []), instance_hash)
|
||||||
|
|
||||||
# Check content bloom for dedup
|
# Check content bloom for dedup
|
||||||
peer_bloom_data = data.get("content_bloom")
|
peer_bloom_data = data.get("content_bloom")
|
||||||
peer_bloom = None
|
peer_bloom = None
|
||||||
|
|
@ -490,20 +512,28 @@ class ForumSync:
|
||||||
)
|
)
|
||||||
|
|
||||||
for t in data.get("threads", []):
|
for t in data.get("threads", []):
|
||||||
if t.get("author_instance", "") not in my_blocks:
|
author = t.get("author_instance", "")
|
||||||
if peer_bloom and peer_bloom.might_contain(t["id"]):
|
if author in my_blocks:
|
||||||
continue
|
continue
|
||||||
if not my_topics:
|
if author and not self.fdb.is_trusted(author):
|
||||||
|
continue
|
||||||
|
if peer_bloom and peer_bloom.might_contain(t["id"]):
|
||||||
|
continue
|
||||||
|
if not my_topics:
|
||||||
|
self.fdb.merge_thread(t)
|
||||||
|
else:
|
||||||
|
t_tags = [tag.strip().lower() for tag in t.get("tags", "").split(",") if tag.strip()]
|
||||||
|
if set(my_topics) & set(t_tags):
|
||||||
self.fdb.merge_thread(t)
|
self.fdb.merge_thread(t)
|
||||||
else:
|
|
||||||
t_tags = [tag.strip().lower() for tag in t.get("tags", "").split(",") if tag.strip()]
|
|
||||||
if set(my_topics) & set(t_tags):
|
|
||||||
self.fdb.merge_thread(t)
|
|
||||||
for p in data.get("posts", []):
|
for p in data.get("posts", []):
|
||||||
if p.get("author_instance", "") not in my_blocks:
|
author = p.get("author_instance", "")
|
||||||
if peer_bloom and peer_bloom.might_contain(p["id"]):
|
if author in my_blocks:
|
||||||
continue
|
continue
|
||||||
self.fdb.merge_post(p)
|
if author and not self.fdb.is_trusted(author):
|
||||||
|
continue
|
||||||
|
if peer_bloom and peer_bloom.might_contain(p["id"]):
|
||||||
|
continue
|
||||||
|
self.fdb.merge_post(p)
|
||||||
for tid in data.get("upvote_threads", []):
|
for tid in data.get("upvote_threads", []):
|
||||||
self.fdb.merge_upvote(tid, instance_hash)
|
self.fdb.merge_upvote(tid, instance_hash)
|
||||||
|
|
||||||
|
|
@ -546,6 +576,7 @@ class ForumSync:
|
||||||
if h not in blocked and h not in auto_blocked and count >= 3:
|
if h not in blocked and h not in auto_blocked and count >= 3:
|
||||||
blocked.add(h)
|
blocked.add(h)
|
||||||
auto_blocked.add(h)
|
auto_blocked.add(h)
|
||||||
|
self.fdb.remove_by_hash(h)
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self.fdb.set_setting("blocked_instances", ",".join(blocked))
|
self.fdb.set_setting("blocked_instances", ",".join(blocked))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue