update README to match TinyWeb style
This commit is contained in:
parent
ea34a7f5d7
commit
94afb56bc0
2 changed files with 308 additions and 175 deletions
78
README.md
78
README.md
|
|
@ -1,6 +1,17 @@
|
|||
# tinyweb-forum
|
||||
|
||||
A decentralized link-sharing forum for [TinyWeb](https://codeberg.org/tinyweb/tinyweb). Share URLs and discuss them with other TinyWeb instances over the Reticulum mesh. No accounts, no sign-up, no central server.
|
||||
A link-sharing forum plugin for [TinyWeb](https://codeberg.org/tinyweb/tinyweb). Threads and posts are stored locally and exchanged with other TinyWeb instances over the Reticulum mesh.
|
||||
|
||||
## Contents
|
||||
|
||||
- [About this project](#about-this-project)
|
||||
- [Install](#install)
|
||||
- [How it works](#how-it-works)
|
||||
- [Moderation](#moderation)
|
||||
- [Sync](#sync)
|
||||
- [Storage](#storage)
|
||||
- [Known rough edges](#known-rough-edges)
|
||||
- [Security](#security)
|
||||
|
||||
## About this project
|
||||
|
||||
|
|
@ -12,64 +23,59 @@ Code generated by LLMs. Built by one person.
|
|||
pip install tinyweb-forum
|
||||
```
|
||||
|
||||
Enable the forum in TinyWeb's customize page (`/style`).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://codeberg.org/tinyweb/tinyweb-forum
|
||||
pip install -e .
|
||||
```
|
||||
Enable it on TinyWeb's `/style` page under "Forum".
|
||||
|
||||
## How it works
|
||||
|
||||
- Each TinyWeb instance stores forum threads and posts in its own `forum.db`
|
||||
- Instances auto-discover each other on the mesh via RNS announces — no manual setup
|
||||
- You click "sync now" to exchange content; auto-sync every 5 minutes is optional (toggle on moderation page)
|
||||
- At scale, sync uses epidemic gossip: each cycle picks 20 random peers instead of all peers, converging within ~O(log N) cycles
|
||||
- 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)
|
||||
- **Peer discovery** — instances share known peers during sync, growing the network organically
|
||||
- Threads and posts are stored in `~/.tinyweb/forum.db` (separate from TinyWeb's search index)
|
||||
- Instances auto-discover each other via RNS announces — no manual setup
|
||||
- Sync is manual by default: click "sync now" on the forum page. Auto-sync every 5 minutes is optional (toggle on moderation page)
|
||||
- At scale, sync uses epidemic gossip: 20 random peers per cycle, converging within ~O(log N) cycles
|
||||
- Authors are identified by a short pseudonymous identity hash (no accounts, no sign-up)
|
||||
- Auto-discovery can be disabled in the moderation page
|
||||
- Threads are auto-pruned after 30 days (configurable, or set to 0 to keep everything)
|
||||
- Moderation is local: block authors, mute threads, keyword filters, and gossip block lists with peers (auto-block after 3 peer reports)
|
||||
|
||||
## Moderation
|
||||
|
||||
All moderation is local — you control your view:
|
||||
All moderation is local — it controls what you see:
|
||||
|
||||
- **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 (threshold is 3)
|
||||
- **Mute thread** — hide a thread from the listing
|
||||
- **Keyword filters** — hide threads matching keywords
|
||||
- **Block author** — hides all content from that identity
|
||||
- **Auto-block** — when 3+ of your peers have blocked the same identity, it blocks for you too
|
||||
- **Mute thread** — hides a thread from the listing
|
||||
- **Keyword filters** — hides threads matching keywords
|
||||
- **Instance sync** — choose which peers to sync with; unsync at any time
|
||||
|
||||
## Sync
|
||||
|
||||
- Instances auto-discover each other via RNS announces — just run the forum and any other forum on the mesh finds you
|
||||
- Instances discover each other via RNS announces — any forum on the mesh finds you
|
||||
- Content is exchanged as JSON over RNS links
|
||||
- **Manual by default** — click "sync now" on the forum listing or moderation page
|
||||
- Auto-sync every 5 minutes can be enabled on the moderation page
|
||||
- Auto-discovery can also be disabled on the moderation page (manual instance add only)
|
||||
- Peer discovery propagates through gossip — each instance shares its known peers during sync
|
||||
- Auto-discovery can be disabled (manual instance add only)
|
||||
- Known peers propagate through gossip — each instance shares its peers during sync
|
||||
- Block lists and retractions are gossiped alongside content
|
||||
- Only new/updated content is transferred (timestamp-based)
|
||||
- When >20 peers, each cycle syncs with a random 20 — content converges epidemically
|
||||
|
||||
## Storage
|
||||
|
||||
- Threads older than 30 days are auto-pruned (configurable on the moderation page)
|
||||
- Threads older than 30 days are auto-pruned (configurable on moderation page)
|
||||
- Set retention to 0 to keep everything indefinitely
|
||||
- Forum DB is stored at `~/.tinyweb/forum.db`
|
||||
- Database location: `~/.tinyweb/forum.db`
|
||||
|
||||
## Known rough edges
|
||||
|
||||
- Authors are pseudonymous — no identities, no accounts
|
||||
- No rate limiting on forum POST endpoints
|
||||
- Retractions are voluntary — peers can ignore them
|
||||
- Block gossip can be gamed (requires collusion by 3+ peers on Reticulum)
|
||||
- Threads prune after 30 days by default
|
||||
- Best-effort maintenance
|
||||
|
||||
## Security
|
||||
|
||||
- **No authentication** — The forum inherits TinyWeb's access model. Anyone who can reach the HTTP port (localhost by default) can post, edit, retract, block, and change moderation settings. See TinyWeb's Security section for details on `--bind 0.0.0.0`.
|
||||
- **Retractions are voluntary** — Retracting a thread or post sends a signal to peers, but any peer can ignore it and keep serving the content. "Retract" is a polite request, not a guaranteed delete.
|
||||
- **No authentication** — The forum inherits TinyWeb's access model. Anyone who can reach the HTTP port (localhost by default) can post, edit, retract, block, and change moderation settings. See TinyWeb's Security section for `--bind 0.0.0.0`.
|
||||
- **Retractions are voluntary** — Retracting a thread or post sends a signal to peers, but any peer can ignore it. "Retract" is a polite request, not a guaranteed delete.
|
||||
- **Block gossip can be gamed** — Auto-block triggers after 3 peer reports. On Reticulum this requires 3+ real instances to collude, which is impractical at mesh scale, but is not cryptographically enforced.
|
||||
- **No rate limiting** — Forum POST endpoints have no throttling. Low risk since the HTTP port is localhost-only by default.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class ForumHandlers:
|
|||
self.reticulum = reticulum
|
||||
self.site_name = site_name
|
||||
self._request_local = threading.local()
|
||||
self._flash = {}
|
||||
|
||||
def _get_csrf(self):
|
||||
return getattr(self._request_local, 'csrf_token', '')
|
||||
|
|
@ -42,6 +43,12 @@ class ForumHandlers:
|
|||
return False
|
||||
return secrets.compare_digest(token, expected)
|
||||
|
||||
def _set_flash(self, msg):
|
||||
self._flash[self._get_csrf()] = msg
|
||||
|
||||
def _get_flash(self):
|
||||
return self._flash.pop(self._get_csrf(), "")
|
||||
|
||||
def _is_local(self, instance):
|
||||
if instance == "local":
|
||||
return True
|
||||
|
|
@ -57,7 +64,7 @@ class ForumHandlers:
|
|||
def _block_link(self, instance):
|
||||
if self._is_local(instance):
|
||||
return ""
|
||||
return f' <a class="forum-action-inline" href="/forum/blockhash/{instance}">[block]</a>'
|
||||
return f' <a href="/forum/blockhash/{instance}">block</a>'
|
||||
|
||||
def _respond(self, body_html, status=200):
|
||||
return {
|
||||
|
|
@ -67,6 +74,14 @@ class ForumHandlers:
|
|||
"headers": {},
|
||||
}
|
||||
|
||||
def _redirect(self, location):
|
||||
return {
|
||||
"status": 302,
|
||||
"content_type": "text/html; charset=utf-8",
|
||||
"body": "",
|
||||
"headers": {"Location": location},
|
||||
}
|
||||
|
||||
def _json(self, data, status=200):
|
||||
return {
|
||||
"status": status,
|
||||
|
|
@ -84,7 +99,7 @@ class ForumHandlers:
|
|||
}
|
||||
|
||||
def _error(self, status):
|
||||
return self._respond(f"<h2>{status}</h2>", status)
|
||||
return self._respond(f"<h1>{status}</h1>", status)
|
||||
|
||||
def _paginate(self, query):
|
||||
try:
|
||||
|
|
@ -104,7 +119,7 @@ class ForumHandlers:
|
|||
parts.append(f"page {page} of {total_pages}")
|
||||
if page < total_pages:
|
||||
parts.append(f'<a href="{base_url}{sep}p={page + 1}">next »</a>')
|
||||
return f'<p class="pagination">{" | ".join(parts)}</p>'
|
||||
return f'<p>{" | ".join(parts)}</p>'
|
||||
|
||||
def _now(self):
|
||||
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
|
@ -177,10 +192,10 @@ class ForumHandlers:
|
|||
peer_count = len(self.fdb.get_synced_instances())
|
||||
filter_count = self.fdb.get_peer_filter_count()
|
||||
return (
|
||||
f'<div class="forum-status">'
|
||||
f'<span>subscribed: {esc(topics_str)}</span>'
|
||||
f'<span>{peer_count} peers</span>'
|
||||
f'<span>{filter_count} filters</span>'
|
||||
f'<div style="font-size:0.85rem;color:#606060">'
|
||||
f'subscribed: {esc(topics_str)}'
|
||||
f' · {peer_count} peers'
|
||||
f' · {filter_count} filters'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
|
|
@ -204,30 +219,30 @@ class ForumHandlers:
|
|||
continue
|
||||
if self._is_new(r["created_at"]):
|
||||
new_count += 1
|
||||
badge = f'<span class="thread-badge">share</span>' if r["url"] else f'<span class="thread-badge">request</span>'
|
||||
badge = "share" if r["url"] else "request"
|
||||
mute_label = " [muted]" if is_muted else ""
|
||||
tags_html = ""
|
||||
if r["tags"]:
|
||||
tag_links = " ".join(
|
||||
f'<a href="/forum?tag={esc(t.strip())}" class="tag">{esc(t.strip())}</a>'
|
||||
f'<a href="/forum?tag={esc(t.strip())}">[{esc(t.strip())}]</a>'
|
||||
for t in r["tags"].split(",") if t.strip()
|
||||
)
|
||||
tags_html = f' {tag_links}'
|
||||
reply_label = f"{r['reply_count']} replies" if r['reply_count'] else "no replies"
|
||||
items += (
|
||||
f'<li>'
|
||||
f'<div class="thread-title">'
|
||||
f'{badge}{mute_label} '
|
||||
f'<a href="/forum/t/{esc(r["id"])}">{esc(r["title"])}</a>'
|
||||
f'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem">'
|
||||
f'<div style="margin-bottom:0.4rem">'
|
||||
f'<b><a href="/forum/t/{esc(r["id"])}" style="text-decoration:none;color:inherit">{esc(r["title"])}</a></b>'
|
||||
f'<small> [{badge}]{mute_label}</small>'
|
||||
f'{tags_html}'
|
||||
f'</div>'
|
||||
f'<div class="thread-meta">'
|
||||
f'<div style="font-size:0.85rem;color:#606060">'
|
||||
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}'
|
||||
f'</div>'
|
||||
f'</li>'
|
||||
f'</div>'
|
||||
)
|
||||
if not items:
|
||||
items = "<p>no threads yet.</p>"
|
||||
|
|
@ -238,43 +253,50 @@ class ForumHandlers:
|
|||
f'</form>'
|
||||
)
|
||||
tag_label = f' — {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>'
|
||||
muted_link = f'<a href="/forum?muted=1">show muted</a>' if not show_muted else f'<a 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(
|
||||
f"<h2>forum{tag_label}</h2>"
|
||||
f"<h1>forum{tag_label}"
|
||||
f'<span style="font-size:0.85rem;color:#606060;font-weight:normal;vertical-align:middle"> — {total} threads{new_label}</span>'
|
||||
f"</h1>"
|
||||
f'{self._status_bar()}'
|
||||
f'<div class="forum-toolbar">'
|
||||
f"<br>"
|
||||
f'{search_form}'
|
||||
f'<div class="forum-toolbar-actions">'
|
||||
f'<a class="forum-action" href="/forum/new">+ new</a>'
|
||||
f'<a class="forum-action" href="/forum/moderation">mod</a>'
|
||||
f'<a class="forum-action" href="/forum/sync/now">sync now</a>'
|
||||
f'{muted_link}'
|
||||
f'</div></div>'
|
||||
f"<p>{total} threads{new_label}</p>"
|
||||
f'<ul class="forum-list">{items}</ul>'
|
||||
f"<p>"
|
||||
f'<b><a href="/forum/new">+ new thread</a></b>'
|
||||
f'<span style="font-size:0.85rem">'
|
||||
f' · <a href="/forum/moderation">mod</a>'
|
||||
f' · <a href="/forum/sync/now">sync now</a>'
|
||||
f' · {muted_link}'
|
||||
f"</span>"
|
||||
f"</p>"
|
||||
f'<hr>{items}'
|
||||
f"{self._page_nav(page, total, page_url)}"
|
||||
f'<br><a href="/">back</a>'
|
||||
)
|
||||
|
||||
def handle_new_form(self, msg=""):
|
||||
return self._respond(
|
||||
f"<h2>new thread</h2>"
|
||||
f'<form class="forum-form" method="post" action="/forum/new">'
|
||||
f"<h1>new thread</h1>"
|
||||
f"<p>Share a URL or start a discussion.</p>"
|
||||
f'<form method="post" action="/forum/new">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<label>title</label>'
|
||||
f'<input name="title" placeholder="title" required>'
|
||||
f"<small>max {MAX_TITLE_LENGTH} characters</small>"
|
||||
f'<label>URL</label>'
|
||||
f'<input name="url" placeholder="URL you want to share (optional)">'
|
||||
f'<label>body</label>'
|
||||
f'<textarea name="body" rows="6" placeholder="details or context (optional)"></textarea>'
|
||||
f"<small>max {MAX_BODY_LENGTH} characters</small>"
|
||||
f'<label>tags</label>'
|
||||
f'<input name="tags" placeholder="comma-separated (optional)">'
|
||||
f'<label>Title:</label><br>'
|
||||
f'<input name="title" placeholder="title" size="50" required>'
|
||||
f"<br><small>max {MAX_TITLE_LENGTH} characters</small><br><br>"
|
||||
f'<label>URL:</label><br>'
|
||||
f'<input name="url" placeholder="https://example.com (optional)" size="50">'
|
||||
f"<br><br>"
|
||||
f'<label>Body:</label><br>'
|
||||
f'<textarea name="body" rows="6" cols="50" placeholder="details or context (optional)"></textarea>'
|
||||
f"<br><small>max {MAX_BODY_LENGTH} characters</small><br><br>"
|
||||
f'<label>Tags:</label><br>'
|
||||
f'<input name="tags" placeholder="comma-separated (optional)" size="50">'
|
||||
f"<br><br>"
|
||||
f'<button type="submit">post</button>'
|
||||
f"</form>"
|
||||
f"<p>{msg}</p>"
|
||||
f'<div class="forum-nav"><a href="/forum">back</a></div>'
|
||||
f'<a href="/forum">back</a>'
|
||||
)
|
||||
|
||||
def handle_new_submit(self, body):
|
||||
|
|
@ -308,7 +330,7 @@ class ForumHandlers:
|
|||
instance_hash = self.identity.hash.hex() if self.identity else "local"
|
||||
has_upvoted = self.fdb.has_upvoted(thread_id, instance_hash)
|
||||
|
||||
badge = f'<span class="thread-badge">share</span>' if thread["url"] else f'<span class="thread-badge">request</span>'
|
||||
badge = "share" if thread["url"] else "request"
|
||||
url_html = ""
|
||||
if thread["url"]:
|
||||
url_html = (
|
||||
|
|
@ -318,7 +340,7 @@ class ForumHandlers:
|
|||
tags_html = ""
|
||||
if thread["tags"]:
|
||||
tag_links = " ".join(
|
||||
f'<a href="/forum?tag={esc(t.strip())}" class="tag">{esc(t.strip())}</a>'
|
||||
f'<a href="/forum?tag={esc(t.strip())}">[{esc(t.strip())}]</a>'
|
||||
for t in thread["tags"].split(",") if t.strip()
|
||||
)
|
||||
tags_html = f'<p>{tag_links}</p>'
|
||||
|
|
@ -339,8 +361,8 @@ class ForumHandlers:
|
|||
if p["parent_id"]:
|
||||
parent_ref = f' <a href="#post-{esc(p["parent_id"])}">↪ reply</a>'
|
||||
posts_html += (
|
||||
f'<div class="post" id="post-{esc(p["id"])}">'
|
||||
f'<div class="post-meta">'
|
||||
f'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem" id="post-{esc(p["id"])}">'
|
||||
f'<div style="margin-bottom:0.4rem;font-size:0.85rem;color:#606060">'
|
||||
f'<b>{esc(self._author_str(p["author_name"], p["author_instance"]))}</b>'
|
||||
f'{self._block_link(p["author_instance"])}'
|
||||
f' · {self._time_ago(p["created_at"])}{parent_ref}'
|
||||
|
|
@ -352,32 +374,40 @@ class ForumHandlers:
|
|||
)
|
||||
|
||||
reply_form = (
|
||||
f'<form class="forum-form" method="post" action="/forum/t/{thread["id"]}/reply">'
|
||||
f'<form method="post" action="/forum/t/{thread["id"]}/reply">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<textarea name="body" rows="4" placeholder="share a URL or reply..." required></textarea>'
|
||||
f"<small>max {MAX_BODY_LENGTH} characters</small>"
|
||||
f'<textarea name="body" rows="4" style="width:100%" placeholder="share a URL or reply..." required></textarea>'
|
||||
f"<br><small>max {MAX_BODY_LENGTH} characters</small><br><br>"
|
||||
f'<button type="submit">reply</button>'
|
||||
f"</form>"
|
||||
)
|
||||
|
||||
upvote_label = "-1" if has_upvoted else "+1"
|
||||
return self._respond(
|
||||
f"<h2>{badge} {esc(thread['title'])}</h2>"
|
||||
f'<p>'
|
||||
f"<h1>{esc(thread['title'])}"
|
||||
f'<span style="font-size:0.85rem;color:#606060;font-weight:normal;vertical-align:middle"> [{badge}]</span>'
|
||||
f"</h1>"
|
||||
f'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem">'
|
||||
f'<div style="font-size:0.85rem;color:#606060">'
|
||||
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' · <a href="{mute_href}">{mute_label}</a>'
|
||||
f' · <a href="/forum/t/{thread["id"]}/upvote">{upvote_label}</a>'
|
||||
f'{self._author_links(thread["id"], thread["author_instance"], instance_hash)}'
|
||||
f'</p>'
|
||||
f'</div>'
|
||||
f'{url_html}'
|
||||
f'{body_html}'
|
||||
f'{tags_html}'
|
||||
f'<div style="margin-top:0.4rem">'
|
||||
f'<b><a href="/forum/t/{thread["id"]}/upvote">{upvote_label}</a></b>'
|
||||
f'<span style="font-size:0.85rem">'
|
||||
f' · <a href="{mute_href}">{mute_label}</a>'
|
||||
f'{self._author_links(thread["id"], thread["author_instance"], instance_hash)}'
|
||||
f'</span>'
|
||||
f'</div>'
|
||||
f"</div>"
|
||||
f"<hr>"
|
||||
f"{posts_html}<br><br>"
|
||||
f"{reply_form}<br><br>"
|
||||
f"{posts_html}<br>"
|
||||
f"{reply_form}<br>"
|
||||
f'<a href="/forum">back to forum</a>'
|
||||
)
|
||||
|
||||
|
|
@ -411,15 +441,22 @@ class ForumHandlers:
|
|||
if thread["author_instance"] != instance_hash:
|
||||
return self._error(403)
|
||||
return self._respond(
|
||||
f"<h2>edit thread</h2>"
|
||||
f'<form class="forum-form" method="post" action="/forum/t/{thread_id}/edit">'
|
||||
f"<h1>edit thread</h1>"
|
||||
f"<p>Update your thread.</p>"
|
||||
f'<form method="post" action="/forum/t/{thread_id}/edit">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<input name="title" value="{esc(thread["title"])}" required>'
|
||||
f"<small>max {MAX_TITLE_LENGTH} characters</small>"
|
||||
f'<input name="url" value="{esc(thread["url"] or "")}" placeholder="URL">'
|
||||
f'<textarea name="body" rows="6" placeholder="details or context">{esc(thread["body"] or "")}</textarea>'
|
||||
f"<small>max {MAX_BODY_LENGTH} characters</small>"
|
||||
f'<input name="tags" value="{esc(thread["tags"] or "")}" placeholder="tags, comma-separated">'
|
||||
f'<label>Title:</label><br>'
|
||||
f'<input name="title" value="{esc(thread["title"])}" size="50" required>'
|
||||
f"<br><small>max {MAX_TITLE_LENGTH} characters</small><br><br>"
|
||||
f'<label>URL:</label><br>'
|
||||
f'<input name="url" value="{esc(thread["url"] or "")}" placeholder="https://example.com (optional)" size="50">'
|
||||
f"<br><br>"
|
||||
f'<label>Body:</label><br>'
|
||||
f'<textarea name="body" rows="6" cols="50" placeholder="details or context (optional)">{esc(thread["body"] or "")}</textarea>'
|
||||
f"<br><small>max {MAX_BODY_LENGTH} characters</small><br><br>"
|
||||
f'<label>Tags:</label><br>'
|
||||
f'<input name="tags" value="{esc(thread["tags"] or "")}" placeholder="comma-separated (optional)" size="50">'
|
||||
f"<br><br>"
|
||||
f'<button type="submit">save</button>'
|
||||
f"</form>"
|
||||
f"<p>{msg}</p>"
|
||||
|
|
@ -484,12 +521,12 @@ class ForumHandlers:
|
|||
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>'
|
||||
links += f' · <a href="/forum/t/{tid}/edit">edit</a>'
|
||||
links += f' · <a 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>'
|
||||
return f'<a href="/forum/retract/{tid}/post/{pid}">retract</a>'
|
||||
|
||||
def _peer_reports_html(self):
|
||||
counts = self.fdb.get_peer_block_counts()
|
||||
|
|
@ -497,13 +534,61 @@ class ForumHandlers:
|
|||
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 = ""
|
||||
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>"
|
||||
items.append(f"{esc(h[:16])}... — {count} reports{status}")
|
||||
return '<p style="font-size:0.85rem;color:#606060">' + "<br>".join(items) + "</p>"
|
||||
|
||||
def handle_moderation(self, msg=""):
|
||||
def handle_status(self):
|
||||
synced = self.fdb.get_synced_instances()
|
||||
blocked = self._blocked_instances()
|
||||
rows, total = self.fdb.get_threads(per_page=99999)
|
||||
|
||||
from collections import Counter
|
||||
peer_threads = Counter(r["author_instance"] for r in rows)
|
||||
total_posts = sum(len(list(self.fdb.get_posts(r["id"]))) for r in rows)
|
||||
|
||||
local_hash = self.identity.hash.hex() if self.identity else "local"
|
||||
html = (
|
||||
f"<h1>forum status</h1>"
|
||||
f"<table>"
|
||||
f"<tr><td>this instance</td><td>{esc(local_hash[:16])}...</td></tr>"
|
||||
f"<tr><td>threads</td><td>{total}</td></tr>"
|
||||
f"<tr><td>posts</td><td>{total_posts}</td></tr>"
|
||||
f"<tr><td>known peers</td><td>{len(synced)}</td></tr>"
|
||||
f"<tr><td>auto-discover</td><td>{'on' if self.fdb.get_setting('forum_auto_discover', '1') == '1' else 'off'}</td></tr>"
|
||||
f"<tr><td>auto-sync</td><td>{'on' if self.fdb.get_setting('forum_auto_sync', '0') == '1' else 'off'}</td></tr>"
|
||||
f"<tr><td>retention</td><td>{self.fdb.get_setting('forum_retention_days', '30')} days</td></tr>"
|
||||
f"<tr><td>topic filter</td><td>{esc(self.fdb.get_setting('topic_subscriptions', '') or '(none)')}</td></tr>"
|
||||
f"</table>"
|
||||
f"<br>"
|
||||
f"<h2>peers</h2>"
|
||||
)
|
||||
if not synced:
|
||||
html += "<p>no peers known yet</p>"
|
||||
else:
|
||||
html += "<table>"
|
||||
html += "<tr><th>hash</th><th>name</th><th>threads</th><th>last sync</th></tr>"
|
||||
for s in synced:
|
||||
h = esc(s["instance_hash"][:16]) + "..."
|
||||
name = esc(s["name"] or "-")
|
||||
count = peer_threads.get(s["instance_hash"], 0)
|
||||
last = esc(s["last_sync"] or "never")
|
||||
html += f"<tr><td>{h}</td><td>{name}</td><td>{count}</td><td>{last}</td></tr>"
|
||||
html += "</table>"
|
||||
|
||||
if blocked:
|
||||
html += f"<br><h2>blocked</h2><table>"
|
||||
for h in sorted(blocked):
|
||||
html += f"<tr><td>{esc(h[:16])}...</td></tr>"
|
||||
html += "</table>"
|
||||
|
||||
html += f'<br><a href="/forum">forum</a> · <a href="/forum/moderation">moderation</a>'
|
||||
return self._respond(html)
|
||||
|
||||
def handle_moderation(self, query=None):
|
||||
msg = self._get_flash()
|
||||
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()
|
||||
|
|
@ -522,111 +607,121 @@ class ForumHandlers:
|
|||
label = "auto" if h in auto_blocked else ""
|
||||
reports = f" ({peer_counts.get(h, 0)} reports)" if h in peer_counts else ""
|
||||
blocked_items += (
|
||||
f'<li>'
|
||||
f'{esc(h[:16])}...'
|
||||
f'{" [" + label + "]" if label else ""}{reports} '
|
||||
f'<form method="post" action="/forum/unblock" style="display:inline">'
|
||||
f'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem">'
|
||||
f'<div style="margin-bottom:0.4rem"><b>{esc(h[:16])}...</b>'
|
||||
f'{" [" + label + "]" if label else ""}{reports}</div>'
|
||||
f'<form method="post" action="/forum/unblock" style="margin:0">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<input type="hidden" name="instance" value="{esc(h)}">'
|
||||
f'<button>unblock</button></form>'
|
||||
f'</li>'
|
||||
f'<input type="submit" value="unblock">'
|
||||
f"</form>"
|
||||
f'</div>'
|
||||
)
|
||||
blocked_items = f"<ul>{blocked_items}</ul>"
|
||||
blocked_items = blocked_items
|
||||
else:
|
||||
blocked_items = "<p>no instances blocked</p>"
|
||||
|
||||
synced_items = ""
|
||||
for s in synced:
|
||||
synced_items += (
|
||||
f'<li>{esc(s["name"] or s["instance_hash"][:16])}... '
|
||||
f'<form method="post" action="/forum/unsync" style="display:inline">'
|
||||
f'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem">'
|
||||
f'<div style="margin-bottom:0.4rem"><b>{esc(s["name"] or s["instance_hash"][:16])}...</b></div>'
|
||||
f'<form method="post" action="/forum/unsync" style="display:inline-block;margin:0">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<input type="hidden" name="instance" value="{esc(s["instance_hash"])}">'
|
||||
f'<button>remove</button></form>'
|
||||
f'</li>'
|
||||
f'</div>'
|
||||
)
|
||||
synced_items = f"<ul>{synced_items}</ul>" if synced_items else "<p>no instances synced</p>"
|
||||
synced_items = synced_items or "<p>no instances synced</p>"
|
||||
|
||||
msg_html = f'<p><em>{esc(msg)}</em></p>' if msg else ""
|
||||
|
||||
return self._respond(
|
||||
f"<h2>moderation</h2>"
|
||||
f"<p>{msg}</p>"
|
||||
f"<h1>moderation</h1>"
|
||||
f"{msg_html}"
|
||||
f"<nav>"
|
||||
f'<a href="#subscriptions">subscriptions</a>'
|
||||
f' · <a href="#settings">settings</a>'
|
||||
f' · <a href="#network">network</a>'
|
||||
f' · <a href="#moderation">moderation</a>'
|
||||
f"</nav>"
|
||||
f'{self._status_bar()}'
|
||||
f"<hr>"
|
||||
|
||||
f'<div class="section">'
|
||||
f'<div class="section-title">subscriptions</div>'
|
||||
f'<form class="forum-form" method="post" action="/forum/topics">'
|
||||
f"<section id=\"subscriptions\">"
|
||||
f"<h2>subscriptions</h2>"
|
||||
f'<form method="post" action="/forum/topics">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<p>Only sync content matching these topics.</p>'
|
||||
f'<input name="topics" value="{esc(self._get_subscribed_topics_str())}" placeholder="comma-separated topics (leave empty for all)">'
|
||||
f"<small>only sync content matching these topics</small>"
|
||||
f'<button>save</button>'
|
||||
f'<br><br><input type="submit" value="save subscriptions">'
|
||||
f"</form>"
|
||||
f'</div>'
|
||||
f"</section>"
|
||||
f"<hr>"
|
||||
|
||||
f'<div class="section">'
|
||||
f'<div class="section-title">settings</div>'
|
||||
f'<div class="section-desc">network behavior</div>'
|
||||
f'<form class="forum-form" method="post" action="/forum/auto_discover">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<label class="checkbox-label"><input type="checkbox" name="enabled" value="1"{auto_discover_checked}>'
|
||||
f" auto-discover peers via announces</label>"
|
||||
f'<button>save</button>'
|
||||
f"</form>"
|
||||
f'<form class="forum-form" method="post" action="/forum/auto_sync">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<label class="checkbox-label"><input type="checkbox" name="enabled" value="1"{auto_sync_checked}>'
|
||||
f" auto-sync every 5 minutes</label>"
|
||||
f'<button>save</button>'
|
||||
f"</form>"
|
||||
f'<form class="forum-form" method="post" action="/forum/storage">'
|
||||
f"<section id=\"settings\">"
|
||||
f"<h2>settings</h2>"
|
||||
f"<p>network behavior</p>"
|
||||
f'<form method="post" action="/forum/settings">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<label><input type="checkbox" name="auto_discover" value="1"{auto_discover_checked}>'
|
||||
f" auto-discover peers via announces</label><br>"
|
||||
f'<label><input type="checkbox" name="auto_sync" value="1"{auto_sync_checked}>'
|
||||
f" auto-sync every 5 minutes</label><br>"
|
||||
f'<label>keep threads for '
|
||||
f'<input name="retention_days" value="{esc(retention_days)}" size="5" style="width:50px">'
|
||||
f' days (0 = keep everything)</label>'
|
||||
f'<button>save</button>'
|
||||
f'<input name="retention_days" value="{esc(retention_days)}" size="5">'
|
||||
f' days (0 = keep everything)</label><br><br>'
|
||||
f'<input type="submit" value="save settings">'
|
||||
f"</form>"
|
||||
f'</div>'
|
||||
f"</section>"
|
||||
f"<hr>"
|
||||
|
||||
f'<div class="section">'
|
||||
f'<div class="section-title">network</div>'
|
||||
f'<div class="section-desc">{len(synced)} known peers</div>'
|
||||
f"<section id=\"network\">"
|
||||
f"<h2>network</h2>"
|
||||
f"<p>{len(synced)} known peers</p>"
|
||||
f'{synced_items}'
|
||||
f'<form class="forum-form" method="post" action="/forum/sync/add">'
|
||||
f'<form method="post" action="/forum/sync/add">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<input name="instance" placeholder="instance hash">'
|
||||
f' <input name="name" placeholder="label (optional)">'
|
||||
f'<button>add</button>'
|
||||
f'<br><br><input type="submit" value="add">'
|
||||
f"</form>"
|
||||
f'</div>'
|
||||
f"</section>"
|
||||
f"<hr>"
|
||||
|
||||
f'<div class="section">'
|
||||
f'<div class="section-title">moderation</div>'
|
||||
f"<section id=\"moderation\">"
|
||||
f"<h2>moderation</h2>"
|
||||
f"<h3>blocked instances</h3>"
|
||||
f'{blocked_items}'
|
||||
f'<form class="forum-form" method="post" action="/forum/block">'
|
||||
f'<form method="post" action="/forum/block">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<input name="instance" placeholder="instance hash (32 hex chars)">'
|
||||
f'<button>block</button>'
|
||||
f'<br><br><input type="submit" value="block">'
|
||||
f"</form>"
|
||||
f'<div class="section-title">peer reports</div>'
|
||||
f"<h3>peer reports</h3>"
|
||||
f'{self._peer_reports_html()}'
|
||||
f'<div class="section-title">keyword filters</div>'
|
||||
f'<form class="forum-form" method="post" action="/forum/filters">'
|
||||
f"<h3>keyword filters</h3>"
|
||||
f'<form method="post" action="/forum/filters">'
|
||||
f'{self._csrf_field()}'
|
||||
f'<input name="keywords" value="{esc(filters_str)}" placeholder="comma-separated keywords">'
|
||||
f'<button>save</button>'
|
||||
f'<br><br><input type="submit" value="save filters">'
|
||||
f"</form>"
|
||||
f'</div>'
|
||||
f"</section>"
|
||||
|
||||
f'<div class="forum-nav"><a href="/forum">back</a></div>'
|
||||
f'<a href="/forum">back</a>'
|
||||
f' · <a href="/forum/status">status</a>'
|
||||
)
|
||||
|
||||
def handle_block(self, body):
|
||||
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
|
||||
if len(instance) != 32:
|
||||
return self.handle_moderation("Invalid instance hash (must be 32 hex chars).")
|
||||
self._set_flash("Invalid instance hash (must be 32 hex chars).")
|
||||
return self._redirect("/forum/moderation")
|
||||
blocked = self._blocked_instances()
|
||||
blocked.add(instance)
|
||||
self.fdb.set_setting("blocked_instances", ",".join(blocked))
|
||||
return self.handle_moderation(f"Blocked {instance[:16]}...")
|
||||
self._set_flash(f"Blocked {instance[:16]}...")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_unblock(self, body):
|
||||
instance = body.get("instance", [""])[0].strip()
|
||||
|
|
@ -637,7 +732,8 @@ class ForumHandlers:
|
|||
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]}...")
|
||||
self._set_flash(f"Unblocked {instance[:16]}...")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_block_hash(self, instance):
|
||||
if len(instance) == 32 or len(instance) == 64:
|
||||
|
|
@ -656,30 +752,53 @@ class ForumHandlers:
|
|||
def handle_filters(self, body):
|
||||
keywords = body.get("keywords", [""])[0].strip()
|
||||
self.fdb.set_setting("keyword_filters", keywords)
|
||||
return self.handle_moderation("Filters saved.")
|
||||
self._set_flash("Filters saved.")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_settings(self, body):
|
||||
auto_discover = "1" if body.get("auto_discover") else "0"
|
||||
auto_sync = "1" if body.get("auto_sync") else "0"
|
||||
days = body.get("retention_days", ["30"])[0].strip()
|
||||
try:
|
||||
days = max(0, int(days))
|
||||
except ValueError:
|
||||
self._set_flash("Invalid retention days.")
|
||||
return self._redirect("/forum/moderation")
|
||||
self.fdb.set_setting("forum_auto_discover", auto_discover)
|
||||
self.fdb.set_setting("forum_auto_sync", auto_sync)
|
||||
self.fdb.set_setting("forum_retention_days", str(days))
|
||||
if self.sync:
|
||||
self.sync.set_auto_discover(auto_discover == "1")
|
||||
self.sync.set_auto_sync(auto_sync == "1")
|
||||
self._set_flash("Settings saved.")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_auto_discover(self, body):
|
||||
enabled = "1" if body.get("enabled") else "0"
|
||||
self.fdb.set_setting("forum_auto_discover", enabled)
|
||||
if self.sync:
|
||||
self.sync.set_auto_discover(enabled == "1")
|
||||
return self.handle_moderation(f"Auto-discovery {'enabled' if enabled == '1' else 'disabled'}.")
|
||||
self._set_flash(f"Auto-discovery {'enabled' if enabled == '1' else 'disabled'}.")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_storage(self, body):
|
||||
days = body.get("retention_days", ["30"])[0].strip()
|
||||
try:
|
||||
days = max(0, int(days))
|
||||
except ValueError:
|
||||
return self.handle_moderation("Invalid retention days.")
|
||||
self._set_flash("Invalid retention days.")
|
||||
return self._redirect("/forum/moderation")
|
||||
self.fdb.set_setting("forum_retention_days", str(days))
|
||||
return self.handle_moderation(f"Storage retention set to {days} days.")
|
||||
self._set_flash(f"Storage retention set to {days} days.")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_auto_sync(self, body):
|
||||
enabled = "1" if body.get("enabled") else "0"
|
||||
self.fdb.set_setting("forum_auto_sync", enabled)
|
||||
if self.sync:
|
||||
self.sync.set_auto_sync(enabled == "1")
|
||||
return self.handle_moderation(f"Auto-sync {'enabled' if enabled == '1' else 'disabled'}.")
|
||||
self._set_flash(f"Auto-sync {'enabled' if enabled == '1' else 'disabled'}.")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_sync_now(self):
|
||||
if not self.sync:
|
||||
|
|
@ -694,19 +813,23 @@ class ForumHandlers:
|
|||
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
|
||||
name = body.get("name", [""])[0].strip()
|
||||
if len(instance) != 32:
|
||||
return self.handle_moderation("Invalid instance hash (must be 32 hex chars).")
|
||||
self._set_flash("Invalid instance hash (must be 32 hex chars).")
|
||||
return self._redirect("/forum/moderation")
|
||||
self.fdb.upsert_synced_instance(instance, name)
|
||||
return self.handle_moderation(f"Added {name or instance[:16]}... to sync.")
|
||||
self._set_flash(f"Added {name or instance[:16]}... to sync.")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_unsync(self, body):
|
||||
instance = body.get("instance", [""])[0].strip()
|
||||
self.fdb.remove_synced_instance(instance)
|
||||
return self.handle_moderation("Removed.")
|
||||
self._set_flash("Removed.")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
def handle_topics(self, body):
|
||||
topics = body.get("topics", [""])[0].strip()
|
||||
self.fdb.set_setting("topic_subscriptions", topics)
|
||||
return self.handle_moderation("Topic subscriptions saved.")
|
||||
self._set_flash("Topic subscriptions saved.")
|
||||
return self._redirect("/forum/moderation")
|
||||
|
||||
# --- Sync endpoint (called over RNS) ---
|
||||
|
||||
|
|
@ -924,7 +1047,9 @@ class ForumHandlers:
|
|||
elif sub == "/new":
|
||||
return self._with_csrf(self.handle_new_form(), csrf_token)
|
||||
elif sub == "/moderation":
|
||||
return self._with_csrf(self.handle_moderation(), csrf_token)
|
||||
return self._with_csrf(self.handle_moderation(query), csrf_token)
|
||||
elif sub == "/status":
|
||||
return self._with_csrf(self.handle_status(), csrf_token)
|
||||
elif sub.startswith("/t/"):
|
||||
tid = sub[3:]
|
||||
if tid.endswith("/upvote"):
|
||||
|
|
@ -949,7 +1074,7 @@ class ForumHandlers:
|
|||
elif method == "POST":
|
||||
if not self._check_csrf(body):
|
||||
return self._with_csrf(
|
||||
self._respond("<h2>403 Forbidden</h2>", status=403), csrf_token
|
||||
self._respond("<h1>403 Forbidden</h1>", status=403), csrf_token
|
||||
)
|
||||
if sub == "/new":
|
||||
return self._with_csrf(self.handle_new_submit(body), csrf_token)
|
||||
|
|
@ -986,6 +1111,8 @@ class ForumHandlers:
|
|||
return self._with_csrf(self.handle_auto_sync(body), csrf_token)
|
||||
elif sub == "/topics":
|
||||
return self._with_csrf(self.handle_topics(body), csrf_token)
|
||||
elif sub == "/settings":
|
||||
return self._with_csrf(self.handle_settings(body), csrf_token)
|
||||
|
||||
return self._with_csrf(self._error(404), csrf_token)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue