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
|
# 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
|
## About this project
|
||||||
|
|
||||||
|
|
@ -12,64 +23,59 @@ Code generated by LLMs. Built by one person.
|
||||||
pip install tinyweb-forum
|
pip install tinyweb-forum
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable the forum in TinyWeb's customize page (`/style`).
|
Enable it on TinyWeb's `/style` page under "Forum".
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://codeberg.org/tinyweb/tinyweb-forum
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
- Each TinyWeb instance stores forum threads and posts in its own `forum.db`
|
- Threads and posts are stored in `~/.tinyweb/forum.db` (separate from TinyWeb's search index)
|
||||||
- Instances auto-discover each other on the mesh via RNS announces — no manual setup
|
- Instances auto-discover each other via RNS announces — no manual setup
|
||||||
- You click "sync now" to exchange content; auto-sync every 5 minutes is optional (toggle on moderation page)
|
- 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: each cycle picks 20 random peers instead of all peers, converging within ~O(log N) cycles
|
- At scale, sync uses epidemic gossip: 20 random peers per cycle, converging within ~O(log N) cycles
|
||||||
- Authors are identified by a short pseudonymous hash (no names, no accounts)
|
- Authors are identified by a short pseudonymous identity hash (no accounts, no sign-up)
|
||||||
- No global server, no algorithms, no tracking
|
- Auto-discovery can be disabled in the moderation page
|
||||||
|
- Threads are auto-pruned after 30 days (configurable, or set to 0 to keep everything)
|
||||||
## Features
|
- Moderation is local: block authors, mute threads, keyword filters, and gossip block lists with peers (auto-block after 3 peer reports)
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
## Moderation
|
## 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
|
- **Block author** — hides all content from that identity
|
||||||
- **Auto-block** — when 3+ of your peers have blocked the same identity, it's auto-blocked for you too (threshold is 3)
|
- **Auto-block** — when 3+ of your peers have blocked the same identity, it blocks for you too
|
||||||
- **Mute thread** — hide a thread from the listing
|
- **Mute thread** — hides a thread from the listing
|
||||||
- **Keyword filters** — hide threads matching keywords
|
- **Keyword filters** — hides threads matching keywords
|
||||||
- **Instance sync** — choose which peers to sync with; unsync at any time
|
- **Instance sync** — choose which peers to sync with; unsync at any time
|
||||||
|
|
||||||
## Sync
|
## 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
|
- Content is exchanged as JSON over RNS links
|
||||||
- **Manual by default** — click "sync now" on the forum listing or moderation page
|
- **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-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)
|
- Auto-discovery can be disabled (manual instance add only)
|
||||||
- Peer discovery propagates through gossip — each instance shares its known peers during sync
|
- Known peers propagate through gossip — each instance shares its peers during sync
|
||||||
- Block lists and retractions are gossiped alongside content
|
- Block lists and retractions are gossiped alongside content
|
||||||
- Only new/updated content is transferred (timestamp-based)
|
- Only new/updated content is transferred (timestamp-based)
|
||||||
- When >20 peers, each cycle syncs with a random 20 — content converges epidemically
|
- When >20 peers, each cycle syncs with a random 20 — content converges epidemically
|
||||||
|
|
||||||
## Storage
|
## 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
|
- 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
|
## 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`.
|
- **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 and keep serving the content. "Retract" is a polite request, not a guaranteed delete.
|
- **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.
|
- **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.
|
- **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.reticulum = reticulum
|
||||||
self.site_name = site_name
|
self.site_name = site_name
|
||||||
self._request_local = threading.local()
|
self._request_local = threading.local()
|
||||||
|
self._flash = {}
|
||||||
|
|
||||||
def _get_csrf(self):
|
def _get_csrf(self):
|
||||||
return getattr(self._request_local, 'csrf_token', '')
|
return getattr(self._request_local, 'csrf_token', '')
|
||||||
|
|
@ -42,6 +43,12 @@ class ForumHandlers:
|
||||||
return False
|
return False
|
||||||
return secrets.compare_digest(token, expected)
|
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):
|
def _is_local(self, instance):
|
||||||
if instance == "local":
|
if instance == "local":
|
||||||
return True
|
return True
|
||||||
|
|
@ -57,7 +64,7 @@ class ForumHandlers:
|
||||||
def _block_link(self, instance):
|
def _block_link(self, instance):
|
||||||
if self._is_local(instance):
|
if self._is_local(instance):
|
||||||
return ""
|
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):
|
def _respond(self, body_html, status=200):
|
||||||
return {
|
return {
|
||||||
|
|
@ -67,6 +74,14 @@ class ForumHandlers:
|
||||||
"headers": {},
|
"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):
|
def _json(self, data, status=200):
|
||||||
return {
|
return {
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|
@ -84,7 +99,7 @@ class ForumHandlers:
|
||||||
}
|
}
|
||||||
|
|
||||||
def _error(self, status):
|
def _error(self, status):
|
||||||
return self._respond(f"<h2>{status}</h2>", status)
|
return self._respond(f"<h1>{status}</h1>", status)
|
||||||
|
|
||||||
def _paginate(self, query):
|
def _paginate(self, query):
|
||||||
try:
|
try:
|
||||||
|
|
@ -104,7 +119,7 @@ class ForumHandlers:
|
||||||
parts.append(f"page {page} of {total_pages}")
|
parts.append(f"page {page} of {total_pages}")
|
||||||
if page < total_pages:
|
if page < total_pages:
|
||||||
parts.append(f'<a href="{base_url}{sep}p={page + 1}">next »</a>')
|
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):
|
def _now(self):
|
||||||
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
@ -177,10 +192,10 @@ class ForumHandlers:
|
||||||
peer_count = len(self.fdb.get_synced_instances())
|
peer_count = len(self.fdb.get_synced_instances())
|
||||||
filter_count = self.fdb.get_peer_filter_count()
|
filter_count = self.fdb.get_peer_filter_count()
|
||||||
return (
|
return (
|
||||||
f'<div class="forum-status">'
|
f'<div style="font-size:0.85rem;color:#606060">'
|
||||||
f'<span>subscribed: {esc(topics_str)}</span>'
|
f'subscribed: {esc(topics_str)}'
|
||||||
f'<span>{peer_count} peers</span>'
|
f' · {peer_count} peers'
|
||||||
f'<span>{filter_count} filters</span>'
|
f' · {filter_count} filters'
|
||||||
f'</div>'
|
f'</div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -204,30 +219,30 @@ class ForumHandlers:
|
||||||
continue
|
continue
|
||||||
if self._is_new(r["created_at"]):
|
if self._is_new(r["created_at"]):
|
||||||
new_count += 1
|
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 ""
|
mute_label = " [muted]" if is_muted else ""
|
||||||
tags_html = ""
|
tags_html = ""
|
||||||
if r["tags"]:
|
if r["tags"]:
|
||||||
tag_links = " ".join(
|
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()
|
for t in r["tags"].split(",") if t.strip()
|
||||||
)
|
)
|
||||||
tags_html = f' {tag_links}'
|
tags_html = f' {tag_links}'
|
||||||
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'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem">'
|
||||||
f'<div class="thread-title">'
|
f'<div style="margin-bottom:0.4rem">'
|
||||||
f'{badge}{mute_label} '
|
f'<b><a href="/forum/t/{esc(r["id"])}" style="text-decoration:none;color:inherit">{esc(r["title"])}</a></b>'
|
||||||
f'<a href="/forum/t/{esc(r["id"])}">{esc(r["title"])}</a>'
|
f'<small> [{badge}]{mute_label}</small>'
|
||||||
f'{tags_html}'
|
f'{tags_html}'
|
||||||
f'</div>'
|
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'{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}'
|
f' · {reply_label}'
|
||||||
f'</div>'
|
f'</div>'
|
||||||
f'</li>'
|
f'</div>'
|
||||||
)
|
)
|
||||||
if not items:
|
if not items:
|
||||||
items = "<p>no threads yet.</p>"
|
items = "<p>no threads yet.</p>"
|
||||||
|
|
@ -238,43 +253,50 @@ class ForumHandlers:
|
||||||
f'</form>'
|
f'</form>'
|
||||||
)
|
)
|
||||||
tag_label = f' — {esc(tag)}' if tag else ""
|
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')
|
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"<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'{self._status_bar()}'
|
||||||
f'<div class="forum-toolbar">'
|
f"<br>"
|
||||||
f'{search_form}'
|
f'{search_form}'
|
||||||
f'<div class="forum-toolbar-actions">'
|
f"<p>"
|
||||||
f'<a class="forum-action" href="/forum/new">+ new</a>'
|
f'<b><a href="/forum/new">+ new thread</a></b>'
|
||||||
f'<a class="forum-action" href="/forum/moderation">mod</a>'
|
f'<span style="font-size:0.85rem">'
|
||||||
f'<a class="forum-action" href="/forum/sync/now">sync now</a>'
|
f' · <a href="/forum/moderation">mod</a>'
|
||||||
f'{muted_link}'
|
f' · <a href="/forum/sync/now">sync now</a>'
|
||||||
f'</div></div>'
|
f' · {muted_link}'
|
||||||
f"<p>{total} threads{new_label}</p>"
|
f"</span>"
|
||||||
f'<ul class="forum-list">{items}</ul>'
|
f"</p>"
|
||||||
|
f'<hr>{items}'
|
||||||
f"{self._page_nav(page, total, page_url)}"
|
f"{self._page_nav(page, total, page_url)}"
|
||||||
|
f'<br><a href="/">back</a>'
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle_new_form(self, msg=""):
|
def handle_new_form(self, msg=""):
|
||||||
return self._respond(
|
return self._respond(
|
||||||
f"<h2>new thread</h2>"
|
f"<h1>new thread</h1>"
|
||||||
f'<form class="forum-form" method="post" action="/forum/new">'
|
f"<p>Share a URL or start a discussion.</p>"
|
||||||
|
f'<form method="post" action="/forum/new">'
|
||||||
f'{self._csrf_field()}'
|
f'{self._csrf_field()}'
|
||||||
f'<label>title</label>'
|
f'<label>Title:</label><br>'
|
||||||
f'<input name="title" placeholder="title" required>'
|
f'<input name="title" placeholder="title" size="50" required>'
|
||||||
f"<small>max {MAX_TITLE_LENGTH} characters</small>"
|
f"<br><small>max {MAX_TITLE_LENGTH} characters</small><br><br>"
|
||||||
f'<label>URL</label>'
|
f'<label>URL:</label><br>'
|
||||||
f'<input name="url" placeholder="URL you want to share (optional)">'
|
f'<input name="url" placeholder="https://example.com (optional)" size="50">'
|
||||||
f'<label>body</label>'
|
f"<br><br>"
|
||||||
f'<textarea name="body" rows="6" placeholder="details or context (optional)"></textarea>'
|
f'<label>Body:</label><br>'
|
||||||
f"<small>max {MAX_BODY_LENGTH} characters</small>"
|
f'<textarea name="body" rows="6" cols="50" placeholder="details or context (optional)"></textarea>'
|
||||||
f'<label>tags</label>'
|
f"<br><small>max {MAX_BODY_LENGTH} characters</small><br><br>"
|
||||||
f'<input name="tags" placeholder="comma-separated (optional)">'
|
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'<button type="submit">post</button>'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
f"<p>{msg}</p>"
|
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):
|
def handle_new_submit(self, body):
|
||||||
|
|
@ -308,7 +330,7 @@ class ForumHandlers:
|
||||||
instance_hash = self.identity.hash.hex() if self.identity else "local"
|
instance_hash = self.identity.hash.hex() if self.identity else "local"
|
||||||
has_upvoted = self.fdb.has_upvoted(thread_id, instance_hash)
|
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 = ""
|
url_html = ""
|
||||||
if thread["url"]:
|
if thread["url"]:
|
||||||
url_html = (
|
url_html = (
|
||||||
|
|
@ -318,7 +340,7 @@ class ForumHandlers:
|
||||||
tags_html = ""
|
tags_html = ""
|
||||||
if thread["tags"]:
|
if thread["tags"]:
|
||||||
tag_links = " ".join(
|
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()
|
for t in thread["tags"].split(",") if t.strip()
|
||||||
)
|
)
|
||||||
tags_html = f'<p>{tag_links}</p>'
|
tags_html = f'<p>{tag_links}</p>'
|
||||||
|
|
@ -339,8 +361,8 @@ class ForumHandlers:
|
||||||
if p["parent_id"]:
|
if p["parent_id"]:
|
||||||
parent_ref = f' <a href="#post-{esc(p["parent_id"])}">↪ reply</a>'
|
parent_ref = f' <a href="#post-{esc(p["parent_id"])}">↪ reply</a>'
|
||||||
posts_html += (
|
posts_html += (
|
||||||
f'<div class="post" id="post-{esc(p["id"])}">'
|
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 class="post-meta">'
|
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'<b>{esc(self._author_str(p["author_name"], p["author_instance"]))}</b>'
|
||||||
f'{self._block_link(p["author_instance"])}'
|
f'{self._block_link(p["author_instance"])}'
|
||||||
f' · {self._time_ago(p["created_at"])}{parent_ref}'
|
f' · {self._time_ago(p["created_at"])}{parent_ref}'
|
||||||
|
|
@ -352,32 +374,40 @@ class ForumHandlers:
|
||||||
)
|
)
|
||||||
|
|
||||||
reply_form = (
|
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'{self._csrf_field()}'
|
||||||
f'<textarea name="body" rows="4" placeholder="share a URL or reply..." required></textarea>'
|
f'<textarea name="body" rows="4" style="width:100%" placeholder="share a URL or reply..." required></textarea>'
|
||||||
f"<small>max {MAX_BODY_LENGTH} characters</small>"
|
f"<br><small>max {MAX_BODY_LENGTH} characters</small><br><br>"
|
||||||
f'<button type="submit">reply</button>'
|
f'<button type="submit">reply</button>'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
)
|
)
|
||||||
|
|
||||||
upvote_label = "-1" if has_upvoted else "+1"
|
upvote_label = "-1" if has_upvoted else "+1"
|
||||||
return self._respond(
|
return self._respond(
|
||||||
f"<h2>{badge} {esc(thread['title'])}</h2>"
|
f"<h1>{esc(thread['title'])}"
|
||||||
f'<p>'
|
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'by {esc(self._author_str(thread["author_name"], thread["author_instance"]))}'
|
||||||
f'{self._block_link(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' · <a href="{mute_href}">{mute_label}</a>'
|
f'</div>'
|
||||||
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'{url_html}'
|
f'{url_html}'
|
||||||
f'{body_html}'
|
f'{body_html}'
|
||||||
f'{tags_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"<hr>"
|
||||||
f"{posts_html}<br><br>"
|
f"{posts_html}<br>"
|
||||||
f"{reply_form}<br><br>"
|
f"{reply_form}<br>"
|
||||||
f'<a href="/forum">back to forum</a>'
|
f'<a href="/forum">back to forum</a>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -411,15 +441,22 @@ class ForumHandlers:
|
||||||
if thread["author_instance"] != instance_hash:
|
if thread["author_instance"] != instance_hash:
|
||||||
return self._error(403)
|
return self._error(403)
|
||||||
return self._respond(
|
return self._respond(
|
||||||
f"<h2>edit thread</h2>"
|
f"<h1>edit thread</h1>"
|
||||||
f'<form class="forum-form" method="post" action="/forum/t/{thread_id}/edit">'
|
f"<p>Update your thread.</p>"
|
||||||
|
f'<form method="post" action="/forum/t/{thread_id}/edit">'
|
||||||
f'{self._csrf_field()}'
|
f'{self._csrf_field()}'
|
||||||
f'<input name="title" value="{esc(thread["title"])}" required>'
|
f'<label>Title:</label><br>'
|
||||||
f"<small>max {MAX_TITLE_LENGTH} characters</small>"
|
f'<input name="title" value="{esc(thread["title"])}" size="50" required>'
|
||||||
f'<input name="url" value="{esc(thread["url"] or "")}" placeholder="URL">'
|
f"<br><small>max {MAX_TITLE_LENGTH} characters</small><br><br>"
|
||||||
f'<textarea name="body" rows="6" placeholder="details or context">{esc(thread["body"] or "")}</textarea>'
|
f'<label>URL:</label><br>'
|
||||||
f"<small>max {MAX_BODY_LENGTH} characters</small>"
|
f'<input name="url" value="{esc(thread["url"] or "")}" placeholder="https://example.com (optional)" size="50">'
|
||||||
f'<input name="tags" value="{esc(thread["tags"] or "")}" placeholder="tags, comma-separated">'
|
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'<button type="submit">save</button>'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
f"<p>{msg}</p>"
|
f"<p>{msg}</p>"
|
||||||
|
|
@ -484,12 +521,12 @@ class ForumHandlers:
|
||||||
def _author_links(self, tid, author_instance, instance_hash):
|
def _author_links(self, tid, author_instance, instance_hash):
|
||||||
links = ""
|
links = ""
|
||||||
if author_instance == instance_hash:
|
if author_instance == instance_hash:
|
||||||
links += f' · <a class="forum-action-inline" href="/forum/t/{tid}/edit">edit</a>'
|
links += f' · <a 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/retract/{tid}">retract</a>'
|
||||||
return links
|
return links
|
||||||
|
|
||||||
def _post_retract_link(self, tid, pid):
|
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):
|
def _peer_reports_html(self):
|
||||||
counts = self.fdb.get_peer_block_counts()
|
counts = self.fdb.get_peer_block_counts()
|
||||||
|
|
@ -497,13 +534,61 @@ class ForumHandlers:
|
||||||
return "<p>no peer reports yet</p>"
|
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())
|
auto_blocked = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
|
||||||
blocked = self._blocked_instances()
|
blocked = self._blocked_instances()
|
||||||
items = ""
|
items = []
|
||||||
for h, count in sorted(counts.items(), key=lambda x: -x[1]):
|
for h, count in sorted(counts.items(), key=lambda x: -x[1]):
|
||||||
status = " (blocked)" if h in blocked else " (pending)"
|
status = " (blocked)" if h in blocked else " (pending)"
|
||||||
items += f"<li>{esc(h[:16])}... — {count} reports{status}</li>"
|
items.append(f"{esc(h[:16])}... — {count} reports{status}")
|
||||||
return f"<ul>{items}</ul>"
|
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()
|
blocked = self._blocked_instances()
|
||||||
auto_blocked = set(h.strip() for h in self.fdb.get_setting("auto_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())
|
||||||
peer_counts = self.fdb.get_peer_block_counts()
|
peer_counts = self.fdb.get_peer_block_counts()
|
||||||
|
|
@ -522,111 +607,121 @@ class ForumHandlers:
|
||||||
label = "auto" if h in auto_blocked else ""
|
label = "auto" if h in auto_blocked else ""
|
||||||
reports = f" ({peer_counts.get(h, 0)} reports)" if h in peer_counts else ""
|
reports = f" ({peer_counts.get(h, 0)} reports)" if h in peer_counts else ""
|
||||||
blocked_items += (
|
blocked_items += (
|
||||||
f'<li>'
|
f'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem">'
|
||||||
f'{esc(h[:16])}...'
|
f'<div style="margin-bottom:0.4rem"><b>{esc(h[:16])}...</b>'
|
||||||
f'{" [" + label + "]" if label else ""}{reports} '
|
f'{" [" + label + "]" if label else ""}{reports}</div>'
|
||||||
f'<form method="post" action="/forum/unblock" style="display:inline">'
|
f'<form method="post" action="/forum/unblock" style="margin:0">'
|
||||||
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)}">'
|
||||||
f'<button>unblock</button></form>'
|
f'<input type="submit" value="unblock">'
|
||||||
f'</li>'
|
f"</form>"
|
||||||
|
f'</div>'
|
||||||
)
|
)
|
||||||
blocked_items = f"<ul>{blocked_items}</ul>"
|
blocked_items = blocked_items
|
||||||
else:
|
else:
|
||||||
blocked_items = "<p>no instances blocked</p>"
|
blocked_items = "<p>no instances blocked</p>"
|
||||||
|
|
||||||
synced_items = ""
|
synced_items = ""
|
||||||
for s in synced:
|
for s in synced:
|
||||||
synced_items += (
|
synced_items += (
|
||||||
f'<li>{esc(s["name"] or s["instance_hash"][:16])}... '
|
f'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem">'
|
||||||
f'<form method="post" action="/forum/unsync" style="display:inline">'
|
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'{self._csrf_field()}'
|
||||||
f'<input type="hidden" name="instance" value="{esc(s["instance_hash"])}">'
|
f'<input type="hidden" name="instance" value="{esc(s["instance_hash"])}">'
|
||||||
f'<button>remove</button></form>'
|
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(
|
return self._respond(
|
||||||
f"<h2>moderation</h2>"
|
f"<h1>moderation</h1>"
|
||||||
f"<p>{msg}</p>"
|
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'{self._status_bar()}'
|
||||||
|
f"<hr>"
|
||||||
|
|
||||||
f'<div class="section">'
|
f"<section id=\"subscriptions\">"
|
||||||
f'<div class="section-title">subscriptions</div>'
|
f"<h2>subscriptions</h2>"
|
||||||
f'<form class="forum-form" method="post" action="/forum/topics">'
|
f'<form method="post" action="/forum/topics">'
|
||||||
f'{self._csrf_field()}'
|
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'<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'<br><br><input type="submit" value="save subscriptions">'
|
||||||
f'<button>save</button>'
|
|
||||||
f"</form>"
|
f"</form>"
|
||||||
f'</div>'
|
f"</section>"
|
||||||
|
f"<hr>"
|
||||||
|
|
||||||
f'<div class="section">'
|
f"<section id=\"settings\">"
|
||||||
f'<div class="section-title">settings</div>'
|
f"<h2>settings</h2>"
|
||||||
f'<div class="section-desc">network behavior</div>'
|
f"<p>network behavior</p>"
|
||||||
f'<form class="forum-form" method="post" action="/forum/auto_discover">'
|
f'<form method="post" action="/forum/settings">'
|
||||||
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'{self._csrf_field()}'
|
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'<label>keep threads for '
|
||||||
f'<input name="retention_days" value="{esc(retention_days)}" size="5" style="width:50px">'
|
f'<input name="retention_days" value="{esc(retention_days)}" size="5">'
|
||||||
f' days (0 = keep everything)</label>'
|
f' days (0 = keep everything)</label><br><br>'
|
||||||
f'<button>save</button>'
|
f'<input type="submit" value="save settings">'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
f'</div>'
|
f"</section>"
|
||||||
|
f"<hr>"
|
||||||
|
|
||||||
f'<div class="section">'
|
f"<section id=\"network\">"
|
||||||
f'<div class="section-title">network</div>'
|
f"<h2>network</h2>"
|
||||||
f'<div class="section-desc">{len(synced)} known peers</div>'
|
f"<p>{len(synced)} known peers</p>"
|
||||||
f'{synced_items}'
|
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'{self._csrf_field()}'
|
||||||
f'<input name="instance" placeholder="instance hash">'
|
f'<input name="instance" placeholder="instance hash">'
|
||||||
f' <input name="name" placeholder="label (optional)">'
|
f' <input name="name" placeholder="label (optional)">'
|
||||||
f'<button>add</button>'
|
f'<br><br><input type="submit" value="add">'
|
||||||
f"</form>"
|
f"</form>"
|
||||||
f'</div>'
|
f"</section>"
|
||||||
|
f"<hr>"
|
||||||
|
|
||||||
f'<div class="section">'
|
f"<section id=\"moderation\">"
|
||||||
f'<div class="section-title">moderation</div>'
|
f"<h2>moderation</h2>"
|
||||||
|
f"<h3>blocked instances</h3>"
|
||||||
f'{blocked_items}'
|
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'{self._csrf_field()}'
|
||||||
f'<input name="instance" placeholder="instance hash (32 hex chars)">'
|
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"</form>"
|
||||||
f'<div class="section-title">peer reports</div>'
|
f"<h3>peer reports</h3>"
|
||||||
f'{self._peer_reports_html()}'
|
f'{self._peer_reports_html()}'
|
||||||
f'<div class="section-title">keyword filters</div>'
|
f"<h3>keyword filters</h3>"
|
||||||
f'<form class="forum-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">'
|
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"</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):
|
def handle_block(self, body):
|
||||||
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
|
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
|
||||||
if len(instance) != 32:
|
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 = 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))
|
||||||
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):
|
def handle_unblock(self, body):
|
||||||
instance = body.get("instance", [""])[0].strip()
|
instance = body.get("instance", [""])[0].strip()
|
||||||
|
|
@ -637,7 +732,8 @@ class ForumHandlers:
|
||||||
auto.discard(instance)
|
auto.discard(instance)
|
||||||
self.fdb.set_setting("auto_blocked_instances", ",".join(auto))
|
self.fdb.set_setting("auto_blocked_instances", ",".join(auto))
|
||||||
self.fdb.clear_peer_block(instance)
|
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):
|
def handle_block_hash(self, instance):
|
||||||
if len(instance) == 32 or len(instance) == 64:
|
if len(instance) == 32 or len(instance) == 64:
|
||||||
|
|
@ -656,30 +752,53 @@ class ForumHandlers:
|
||||||
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)
|
||||||
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):
|
def handle_auto_discover(self, body):
|
||||||
enabled = "1" if body.get("enabled") else "0"
|
enabled = "1" if body.get("enabled") else "0"
|
||||||
self.fdb.set_setting("forum_auto_discover", enabled)
|
self.fdb.set_setting("forum_auto_discover", enabled)
|
||||||
if self.sync:
|
if self.sync:
|
||||||
self.sync.set_auto_discover(enabled == "1")
|
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):
|
def handle_storage(self, body):
|
||||||
days = body.get("retention_days", ["30"])[0].strip()
|
days = body.get("retention_days", ["30"])[0].strip()
|
||||||
try:
|
try:
|
||||||
days = max(0, int(days))
|
days = max(0, int(days))
|
||||||
except ValueError:
|
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))
|
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):
|
def handle_auto_sync(self, body):
|
||||||
enabled = "1" if body.get("enabled") else "0"
|
enabled = "1" if body.get("enabled") else "0"
|
||||||
self.fdb.set_setting("forum_auto_sync", enabled)
|
self.fdb.set_setting("forum_auto_sync", enabled)
|
||||||
if self.sync:
|
if self.sync:
|
||||||
self.sync.set_auto_sync(enabled == "1")
|
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):
|
def handle_sync_now(self):
|
||||||
if not self.sync:
|
if not self.sync:
|
||||||
|
|
@ -694,19 +813,23 @@ class ForumHandlers:
|
||||||
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
|
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
|
||||||
name = body.get("name", [""])[0].strip()
|
name = body.get("name", [""])[0].strip()
|
||||||
if len(instance) != 32:
|
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)
|
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):
|
def handle_unsync(self, body):
|
||||||
instance = body.get("instance", [""])[0].strip()
|
instance = body.get("instance", [""])[0].strip()
|
||||||
self.fdb.remove_synced_instance(instance)
|
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):
|
def handle_topics(self, body):
|
||||||
topics = body.get("topics", [""])[0].strip()
|
topics = body.get("topics", [""])[0].strip()
|
||||||
self.fdb.set_setting("topic_subscriptions", topics)
|
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) ---
|
# --- Sync endpoint (called over RNS) ---
|
||||||
|
|
||||||
|
|
@ -924,7 +1047,9 @@ class ForumHandlers:
|
||||||
elif sub == "/new":
|
elif sub == "/new":
|
||||||
return self._with_csrf(self.handle_new_form(), csrf_token)
|
return self._with_csrf(self.handle_new_form(), csrf_token)
|
||||||
elif sub == "/moderation":
|
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/"):
|
elif sub.startswith("/t/"):
|
||||||
tid = sub[3:]
|
tid = sub[3:]
|
||||||
if tid.endswith("/upvote"):
|
if tid.endswith("/upvote"):
|
||||||
|
|
@ -949,7 +1074,7 @@ class ForumHandlers:
|
||||||
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(
|
||||||
self._respond("<h2>403 Forbidden</h2>", status=403), csrf_token
|
self._respond("<h1>403 Forbidden</h1>", status=403), csrf_token
|
||||||
)
|
)
|
||||||
if sub == "/new":
|
if sub == "/new":
|
||||||
return self._with_csrf(self.handle_new_submit(body), csrf_token)
|
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)
|
return self._with_csrf(self.handle_auto_sync(body), csrf_token)
|
||||||
elif sub == "/topics":
|
elif sub == "/topics":
|
||||||
return self._with_csrf(self.handle_topics(body), csrf_token)
|
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)
|
return self._with_csrf(self._error(404), csrf_token)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue