fix README URLs, handler tests, sync improvements, block/retract gossip
This commit is contained in:
parent
a8aabb3427
commit
a2d029097d
4 changed files with 440 additions and 36 deletions
33
README.md
33
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# tinyweb-forum
|
# 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
|
## Install
|
||||||
|
|
||||||
|
|
@ -13,13 +13,38 @@ Enable the forum in TinyWeb's customize page (`/style`).
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/derickfay/tinyweb-forum
|
git clone https://git.derickphan.com/lichenblankie/tinyweb-forum
|
||||||
pip install -e .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
- Each TinyWeb instance stores forum threads and posts in its own `forum.db`
|
- Each TinyWeb instance stores forum threads and posts in its own `forum.db`
|
||||||
- Instances sync content with each other over RNS
|
- Instances sync content with each other over RNS every 5 minutes
|
||||||
- Moderation is per-instance: block instances, mute threads, keyword filters
|
- Authors are identified by a short pseudonymous hash (no names, no accounts)
|
||||||
- No global server, no algorithms, no tracking
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -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_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_updated ON threads(updated_at)")
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_threads_created ON threads(created_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.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -213,6 +229,16 @@ class ForumDB:
|
||||||
finally:
|
finally:
|
||||||
self.return_db(db)
|
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):
|
def get_synced_instances(self):
|
||||||
db = self.get_db()
|
db = self.get_db()
|
||||||
try:
|
try:
|
||||||
|
|
@ -274,6 +300,17 @@ class ForumDB:
|
||||||
finally:
|
finally:
|
||||||
self.return_db(db)
|
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):
|
def merge_thread(self, thread):
|
||||||
db = self.get_db()
|
db = self.get_db()
|
||||||
try:
|
try:
|
||||||
|
|
@ -319,3 +356,122 @@ class ForumDB:
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
self.return_db(db)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,16 @@ class ForumHandlers:
|
||||||
return False
|
return False
|
||||||
return secrets.compare_digest(token, expected)
|
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' <a class="forum-action-inline" href="/forum/blockhash/{instance}">[block]</a>'
|
||||||
|
|
||||||
def _respond(self, body_html, status=200):
|
def _respond(self, body_html, status=200):
|
||||||
return {
|
return {
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|
@ -116,6 +126,10 @@ class ForumHandlers:
|
||||||
raw = self.fdb.get_setting("blocked_instances", "")
|
raw = self.fdb.get_setting("blocked_instances", "")
|
||||||
return set(h.strip() for h in raw.split(",") if h.strip())
|
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):
|
def _muted_threads(self):
|
||||||
raw = self.fdb.get_setting("muted_threads", "")
|
raw = self.fdb.get_setting("muted_threads", "")
|
||||||
return set(h.strip() for h in raw.split(",") if h.strip())
|
return set(h.strip() for h in raw.split(",") if h.strip())
|
||||||
|
|
@ -141,16 +155,24 @@ class ForumHandlers:
|
||||||
page = self._paginate(query)
|
page = self._paginate(query)
|
||||||
tag = unquote(query.get("tag", [""])[0]).strip()
|
tag = unquote(query.get("tag", [""])[0]).strip()
|
||||||
search = query.get("q", [""])[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)
|
rows, total = self.fdb.get_threads(page=page, per_page=PER_PAGE, tag=tag, search=search)
|
||||||
muted = self._muted_threads()
|
muted = self._muted_threads()
|
||||||
|
retracted = self._retracted_threads()
|
||||||
new_count = 0
|
new_count = 0
|
||||||
items = ""
|
items = ""
|
||||||
for r in rows:
|
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
|
continue
|
||||||
if self._is_new(r["created_at"]):
|
if self._is_new(r["created_at"]):
|
||||||
new_count += 1
|
new_count += 1
|
||||||
badge = "[share]" if r["url"] else "[request]"
|
badge = "[share]" if r["url"] else "[request]"
|
||||||
|
mute_badge = " [muted]" if is_muted else ""
|
||||||
tags_html = ""
|
tags_html = ""
|
||||||
if r["tags"]:
|
if r["tags"]:
|
||||||
tag_links = " ".join(
|
tag_links = " ".join(
|
||||||
|
|
@ -161,11 +183,11 @@ class ForumHandlers:
|
||||||
reply_label = f"{r['reply_count']} replies" if r['reply_count'] else "no replies"
|
reply_label = f"{r['reply_count']} replies" if r['reply_count'] else "no replies"
|
||||||
items += (
|
items += (
|
||||||
f'<li>'
|
f'<li>'
|
||||||
f'<small>{badge}</small> '
|
f'<small>{badge}{mute_badge}</small> '
|
||||||
f'<a href="/forum/t/{esc(r["id"])}">{esc(r["title"])}</a>'
|
f'<a href="/forum/t/{esc(r["id"])}">{esc(r["title"])}</a>'
|
||||||
f'{tags_html}'
|
f'{tags_html}'
|
||||||
f'<br>'
|
f'<br>'
|
||||||
f'<small>{esc(r["author_name"] or r["author_instance"][:8])}'
|
f'<small>{esc(self._author_str(r["author_name"], r["author_instance"]))}'
|
||||||
f' · {self._time_ago(r["created_at"])}'
|
f' · {self._time_ago(r["created_at"])}'
|
||||||
f' · {r["score"]} upvotes'
|
f' · {r["score"]} upvotes'
|
||||||
f' · {reply_label}</small>'
|
f' · {reply_label}</small>'
|
||||||
|
|
@ -180,15 +202,19 @@ class ForumHandlers:
|
||||||
f'</form>'
|
f'</form>'
|
||||||
)
|
)
|
||||||
tag_label = f' — tag: {esc(tag)}' if tag else ""
|
tag_label = f' — tag: {esc(tag)}' if tag else ""
|
||||||
|
muted_link = f'<a class="forum-action" href="/forum?muted=1">show muted</a>' if not show_muted else f'<a class="forum-action" href="/forum">show all</a>'
|
||||||
|
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(
|
return self._respond(
|
||||||
f"<h1>forum{tag_label}</h1>"
|
f"<h1>forum{tag_label}</h1>"
|
||||||
f"<p>{search_form}"
|
f'<div class="forum-actions">'
|
||||||
f' <a href="/forum/new">+ new thread</a>'
|
f'{search_form}'
|
||||||
f' <a href="/forum/moderation">mod</a>'
|
f' <a class="forum-action" href="/forum/new">+ new</a>'
|
||||||
f"</p>"
|
f' <a class="forum-action" href="/forum/moderation">mod</a>'
|
||||||
|
f' {muted_link}'
|
||||||
|
f"</div>"
|
||||||
f"<p class=\"meta\">{total} threads{new_label}</p>"
|
f"<p class=\"meta\">{total} threads{new_label}</p>"
|
||||||
f"<ul>{items}</ul>"
|
f"<ul>{items}</ul>"
|
||||||
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=""):
|
def handle_new_form(self, msg=""):
|
||||||
|
|
@ -222,11 +248,16 @@ class ForumHandlers:
|
||||||
|
|
||||||
def handle_thread(self, thread_id, query=None):
|
def handle_thread(self, thread_id, query=None):
|
||||||
thread = self.fdb.get_thread(thread_id)
|
thread = self.fdb.get_thread(thread_id)
|
||||||
if not thread:
|
if not thread or not self._passes_filters(thread):
|
||||||
return self._error(404)
|
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()
|
muted = self._muted_threads()
|
||||||
is_muted = thread["id"] in muted
|
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]"
|
badge = "[share]" if thread["url"] else "[request]"
|
||||||
url_html = ""
|
url_html = ""
|
||||||
|
|
@ -246,11 +277,9 @@ class ForumHandlers:
|
||||||
body_html = f"<p>{esc(thread['body'])}</p>" if thread["body"] else ""
|
body_html = f"<p>{esc(thread['body'])}</p>" if thread["body"] else ""
|
||||||
|
|
||||||
mute_btn = (
|
mute_btn = (
|
||||||
f'<form method="post" action="/forum/unmute/{thread["id"]}" style="display:inline">'
|
f'<a class="forum-action-inline" href="/forum/unmute/{thread["id"]}">unmute</a>'
|
||||||
f'{self._csrf_field()}<button>unmute</button></form>'
|
|
||||||
if is_muted else
|
if is_muted else
|
||||||
f'<form method="post" action="/forum/mute/{thread["id"]}" style="display:inline">'
|
f'<a class="forum-action-inline" href="/forum/mute/{thread["id"]}">mute</a>'
|
||||||
f'{self._csrf_field()}<button>mute</button></form>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
posts_html = ""
|
posts_html = ""
|
||||||
|
|
@ -267,8 +296,10 @@ class ForumHandlers:
|
||||||
parent_ref = f' <small><a href="#post-{esc(p["parent_id"])}">↪ reply</a></small>'
|
parent_ref = f' <small><a href="#post-{esc(p["parent_id"])}">↪ reply</a></small>'
|
||||||
posts_html += (
|
posts_html += (
|
||||||
f'<div id="post-{esc(p["id"])}" style="margin-bottom:1rem;padding-left:1rem;border-left:2px solid #ddd">'
|
f'<div id="post-{esc(p["id"])}" style="margin-bottom:1rem;padding-left:1rem;border-left:2px solid #ddd">'
|
||||||
f'<small><b>{esc(p["author_name"] or p["author_instance"][:8])}</b>'
|
f'<small><b>{esc(self._author_str(p["author_name"], p["author_instance"]))}</b>'
|
||||||
f' · {self._time_ago(p["created_at"])}{parent_ref}</small>'
|
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 ""}</small>'
|
||||||
f'<p>{esc(p["body"])}</p>'
|
f'<p>{esc(p["body"])}</p>'
|
||||||
f'{save_links}'
|
f'{save_links}'
|
||||||
f'</div>'
|
f'</div>'
|
||||||
|
|
@ -277,7 +308,7 @@ class ForumHandlers:
|
||||||
reply_form = (
|
reply_form = (
|
||||||
f'<form method="post" action="/forum/t/{thread["id"]}/reply">'
|
f'<form method="post" action="/forum/t/{thread["id"]}/reply">'
|
||||||
f'{self._csrf_field()}'
|
f'{self._csrf_field()}'
|
||||||
f'<textarea name="body" rows="4" cols="50" placeholder="share a URL or reply..." required></textarea><br>'
|
f'<textarea name="body" rows="4" cols="50" placeholder="share a URL or reply..." required></textarea><br><br>'
|
||||||
f'<button type="submit">reply</button>'
|
f'<button type="submit">reply</button>'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
)
|
)
|
||||||
|
|
@ -285,23 +316,83 @@ class ForumHandlers:
|
||||||
return self._respond(
|
return self._respond(
|
||||||
f"<h1>{badge} {esc(thread['title'])}</h1>"
|
f"<h1>{badge} {esc(thread['title'])}</h1>"
|
||||||
f'<p class="meta">'
|
f'<p class="meta">'
|
||||||
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' · {self._time_ago(thread["created_at"])}'
|
||||||
f' · {thread["score"]} upvotes'
|
f' · {thread["score"]} upvotes'
|
||||||
f' · {mute_btn}'
|
f' · {mute_btn}'
|
||||||
f' · <form method="post" action="/forum/t/{thread["id"]}/upvote" style="display:inline">'
|
f' · <a class="forum-action-inline" href="/forum/t/{thread["id"]}/upvote">{"-1" if has_upvoted else "+1"}</a>'
|
||||||
f'{self._csrf_field()}<button>+1</button></form>'
|
f'{self._author_links(thread["id"], thread["author_instance"], instance_hash)}'
|
||||||
f'</p>'
|
f'</p>'
|
||||||
f'{url_html}'
|
f'{url_html}'
|
||||||
f'{body_html}'
|
f'{body_html}'
|
||||||
f'{tags_html}'
|
f'{tags_html}'
|
||||||
f"<hr>"
|
f"<hr>"
|
||||||
f"{posts_html}"
|
f"{posts_html}<br><br>"
|
||||||
f"<hr>"
|
f"{reply_form}<br><br>"
|
||||||
f"{reply_form}"
|
|
||||||
f'<a href="/forum">back to forum</a>'
|
f'<a href="/forum">back to forum</a>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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"<h1>edit thread</h1>"
|
||||||
|
f'<form method="post" action="/forum/t/{thread_id}/edit">'
|
||||||
|
f'{self._csrf_field()}'
|
||||||
|
f'<input name="title" value="{esc(thread["title"])}" size="50" required><br><br>'
|
||||||
|
f'<input name="url" value="{esc(thread["url"] or "")}" placeholder="URL" size="50"><br><br>'
|
||||||
|
f'<textarea name="body" rows="6" cols="50" placeholder="details or context">{(thread["body"] or "")}</textarea><br><br>'
|
||||||
|
f'<input name="tags" value="{esc(thread["tags"] or "")}" placeholder="tags, comma-separated" size="50"><br><br>'
|
||||||
|
f'<button type="submit">save</button>'
|
||||||
|
f"</form>"
|
||||||
|
f"<p>{msg}</p>"
|
||||||
|
f'<a href="/forum/t/{thread_id}">back</a>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
def handle_reply(self, thread_id, body):
|
||||||
body_text = body.get("body", [""])[0].strip()
|
body_text = body.get("body", [""])[0].strip()
|
||||||
if not body_text:
|
if not body_text:
|
||||||
|
|
@ -334,13 +425,39 @@ class ForumHandlers:
|
||||||
self.fdb.set_setting("muted_threads", ",".join(muted))
|
self.fdb.set_setting("muted_threads", ",".join(muted))
|
||||||
return self._redirect(f"/forum/t/{thread_id}")
|
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' · <a class="forum-action-inline" href="/forum/t/{tid}/edit">edit</a>'
|
||||||
|
links += f' · <a class="forum-action-inline" href="/forum/retract/{tid}">retract</a>'
|
||||||
|
return links
|
||||||
|
|
||||||
|
def _post_retract_link(self, tid, pid):
|
||||||
|
return f'<a class="forum-action-inline" href="/forum/retract/{tid}/post/{pid}">retract</a>'
|
||||||
|
|
||||||
|
def _peer_reports_html(self):
|
||||||
|
counts = self.fdb.get_peer_block_counts()
|
||||||
|
if not counts:
|
||||||
|
return "<p>No peer reports yet.</p>"
|
||||||
|
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"<li>{esc(h[:16])}... — {count} reports{status}</li>"
|
||||||
|
return f"<ul>{items}</ul>"
|
||||||
|
|
||||||
def handle_moderation(self, msg=""):
|
def handle_moderation(self, msg=""):
|
||||||
blocked = self._blocked_instances()
|
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 = ""
|
blocked_items = ""
|
||||||
if blocked:
|
if blocked:
|
||||||
for h in sorted(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 += (
|
blocked_items += (
|
||||||
f'<li>{esc(h[:16])}... '
|
f'<li>{label}{esc(h[:16])}...{reports} '
|
||||||
f'<form method="post" action="/forum/unblock" style="display:inline">'
|
f'<form method="post" action="/forum/unblock" style="display:inline">'
|
||||||
f'{self._csrf_field()}'
|
f'{self._csrf_field()}'
|
||||||
f'<input type="hidden" name="instance" value="{esc(h)}">'
|
f'<input type="hidden" name="instance" value="{esc(h)}">'
|
||||||
|
|
@ -374,21 +491,23 @@ class ForumHandlers:
|
||||||
f"{blocked_items}"
|
f"{blocked_items}"
|
||||||
f'<form method="post" action="/forum/block">'
|
f'<form method="post" action="/forum/block">'
|
||||||
f'{self._csrf_field()}'
|
f'{self._csrf_field()}'
|
||||||
f'<input name="instance" placeholder="instance hash (32 hex chars)" size="40"> '
|
f'<input name="instance" placeholder="instance hash (32 hex chars)" size="40"><br><br>'
|
||||||
f'<button>block</button>'
|
f'<button>block</button>'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
|
f"<h2>peer reports</h2>"
|
||||||
|
f"{self._peer_reports_html()}"
|
||||||
f"<h2>keyword filters</h2>"
|
f"<h2>keyword filters</h2>"
|
||||||
f'<form method="post" action="/forum/filters">'
|
f'<form method="post" action="/forum/filters">'
|
||||||
f'{self._csrf_field()}'
|
f'{self._csrf_field()}'
|
||||||
f'<input name="keywords" value="{esc(filters_str)}" placeholder="comma-separated keywords" size="50">'
|
f'<input name="keywords" value="{esc(filters_str)}" placeholder="comma-separated keywords" size="50"><br><br>'
|
||||||
f'<button>save</button>'
|
f'<button>save</button>'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
f"<h2>synced instances</h2>"
|
f"<h2>synced instances</h2>"
|
||||||
f"{synced_items}"
|
f"{synced_items}"
|
||||||
f'<form method="post" action="/forum/sync/add">'
|
f'<form method="post" action="/forum/sync/add">'
|
||||||
f'{self._csrf_field()}'
|
f'{self._csrf_field()}'
|
||||||
f'<input name="instance" placeholder="instance hash" size="40"> '
|
f'<input name="instance" placeholder="instance hash" size="40"><br><br>'
|
||||||
f'<input name="name" placeholder="label (optional)" size="20"> '
|
f'<input name="name" placeholder="label (optional)" size="20"><br><br>'
|
||||||
f'<button>add</button>'
|
f'<button>add</button>'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
f'<hr>'
|
f'<hr>'
|
||||||
|
|
@ -409,8 +528,26 @@ class ForumHandlers:
|
||||||
blocked = self._blocked_instances()
|
blocked = self._blocked_instances()
|
||||||
blocked.discard(instance)
|
blocked.discard(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.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]}...")
|
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):
|
def handle_filters(self, body):
|
||||||
keywords = body.get("keywords", [""])[0].strip()
|
keywords = body.get("keywords", [""])[0].strip()
|
||||||
self.fdb.set_setting("keyword_filters", keywords)
|
self.fdb.set_setting("keyword_filters", keywords)
|
||||||
|
|
@ -438,16 +575,37 @@ class ForumHandlers:
|
||||||
incoming_posts = data.get("posts", [])
|
incoming_posts = data.get("posts", [])
|
||||||
incoming_upvotes = data.get("upvotes", [])
|
incoming_upvotes = data.get("upvotes", [])
|
||||||
|
|
||||||
|
blocked = self._blocked_instances()
|
||||||
if incoming_threads:
|
if incoming_threads:
|
||||||
for t in 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:
|
if incoming_posts:
|
||||||
for p in 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:
|
if incoming_upvotes:
|
||||||
for uv in incoming_upvotes:
|
for uv in incoming_upvotes:
|
||||||
self.fdb.merge_upvote(uv["thread_id"], uv["instance_hash"])
|
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 = [], [], []
|
threads, posts, upvote_threads = [], [], []
|
||||||
if since:
|
if since:
|
||||||
ts, posts_list, up_list = self.fdb.get_new_content(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]
|
posts = [dict(r) for r in posts_list]
|
||||||
upvote_threads = up_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 {
|
return {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"content_type": "application/json",
|
"content_type": "application/json",
|
||||||
|
|
@ -462,6 +623,8 @@ class ForumHandlers:
|
||||||
"threads": threads,
|
"threads": threads,
|
||||||
"posts": posts,
|
"posts": posts,
|
||||||
"upvote_threads": upvote_threads,
|
"upvote_threads": upvote_threads,
|
||||||
|
"blocks": {"mine": my_blocks, "peers": my_peer_blocks},
|
||||||
|
"retractions": retracted,
|
||||||
}),
|
}),
|
||||||
"headers": {},
|
"headers": {},
|
||||||
}
|
}
|
||||||
|
|
@ -500,7 +663,23 @@ class ForumHandlers:
|
||||||
return self._with_csrf(self.handle_moderation(), csrf_token)
|
return self._with_csrf(self.handle_moderation(), csrf_token)
|
||||||
elif sub.startswith("/t/"):
|
elif sub.startswith("/t/"):
|
||||||
tid = sub[3:]
|
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)
|
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":
|
elif method == "POST":
|
||||||
if not self._check_csrf(body):
|
if not self._check_csrf(body):
|
||||||
return self._with_csrf(
|
return self._with_csrf(
|
||||||
|
|
@ -510,6 +689,9 @@ class ForumHandlers:
|
||||||
return self._with_csrf(self.handle_new_submit(body), csrf_token)
|
return self._with_csrf(self.handle_new_submit(body), csrf_token)
|
||||||
elif sub.startswith("/t/"):
|
elif sub.startswith("/t/"):
|
||||||
rest = sub[3:]
|
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:
|
if "/reply" in rest:
|
||||||
tid = rest.split("/reply")[0]
|
tid = rest.split("/reply")[0]
|
||||||
return self._with_csrf(self.handle_reply(tid, body), csrf_token)
|
return self._with_csrf(self.handle_reply(tid, body), csrf_token)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ class ForumSync:
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
|
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)
|
return self.handlers_ref().handle_sync(data)
|
||||||
|
|
||||||
def _sync_loop(self):
|
def _sync_loop(self):
|
||||||
|
|
@ -108,11 +110,20 @@ class ForumSync:
|
||||||
posts = [dict(r) for r in ps]
|
posts = [dict(r) for r in ps]
|
||||||
upvotes = [{"thread_id": tid, "instance_hash": instance_hash} for tid in uv]
|
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 = {
|
request_data = {
|
||||||
"query": {"since": [since]} if since else {},
|
"query": {"since": [since]} if since else {},
|
||||||
"threads": threads,
|
"threads": threads,
|
||||||
"posts": posts,
|
"posts": posts,
|
||||||
"upvotes": upvotes,
|
"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)
|
receipt = link.request("/forum", data=request_data, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
@ -129,12 +140,28 @@ class ForumSync:
|
||||||
data = json.loads(resp["body"])
|
data = json.loads(resp["body"])
|
||||||
except (json.JSONDecodeError, KeyError):
|
except (json.JSONDecodeError, KeyError):
|
||||||
data = {}
|
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", []):
|
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", []):
|
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", []):
|
for tid in data.get("upvote_threads", []):
|
||||||
self.fdb.merge_upvote(tid, instance_hash)
|
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")
|
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
self.fdb.set_last_sync(instance_hash, now)
|
self.fdb.set_last_sync(instance_hash, now)
|
||||||
else:
|
else:
|
||||||
|
|
@ -142,5 +169,19 @@ class ForumSync:
|
||||||
finally:
|
finally:
|
||||||
link.teardown()
|
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):
|
def handle_sync(self, data):
|
||||||
return self.handlers_ref().handle_sync_request(data)
|
return self.handlers_ref().handle_sync_request(data)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue