1121 lines
48 KiB
Python
1121 lines
48 KiB
Python
import json
|
|
import secrets
|
|
import threading
|
|
from datetime import datetime
|
|
from urllib.parse import unquote
|
|
|
|
|
|
MAX_TITLE_LENGTH = 200
|
|
MAX_BODY_LENGTH = 10000
|
|
PER_PAGE = 20
|
|
RECENT_SECONDS = 86400 * 7 # "new" = within last 7 days
|
|
|
|
|
|
def esc(s):
|
|
import html
|
|
return html.escape(str(s))
|
|
|
|
|
|
from tinyweb_forum.bloom import BloomFilter
|
|
|
|
|
|
class ForumHandlers:
|
|
def __init__(self, fdb, sync, identity, reticulum, site_name="me"):
|
|
self.fdb = fdb
|
|
self.sync = sync
|
|
self.identity = identity
|
|
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', '')
|
|
|
|
def _csrf_field(self):
|
|
token = self._get_csrf()
|
|
return f'<input type="hidden" name="_csrf" value="{token}">'
|
|
|
|
def _check_csrf(self, body):
|
|
token = body.get("_csrf", [""])[0]
|
|
expected = self._get_csrf()
|
|
if not expected or not token:
|
|
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
|
|
if self.identity and instance == self.identity.hash.hex():
|
|
return True
|
|
return False
|
|
|
|
def _author_str(self, name, instance):
|
|
if self._is_local(instance):
|
|
return "me"
|
|
return instance[:6]
|
|
|
|
def _block_link(self, instance):
|
|
if self._is_local(instance):
|
|
return ""
|
|
return f' <a href="/forum/blockhash/{instance}">block</a>'
|
|
|
|
def _respond(self, body_html, status=200):
|
|
return {
|
|
"status": status,
|
|
"content_type": "text/html; charset=utf-8",
|
|
"body": body_html,
|
|
"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,
|
|
"content_type": "application/json",
|
|
"body": json.dumps(data),
|
|
"headers": {},
|
|
}
|
|
|
|
def _redirect(self, location):
|
|
return {
|
|
"status": 302,
|
|
"content_type": "text/html; charset=utf-8",
|
|
"body": "",
|
|
"headers": {"Location": location},
|
|
}
|
|
|
|
def _error(self, status):
|
|
return self._respond(f"<h1>{status}</h1>", status)
|
|
|
|
def _paginate(self, query):
|
|
try:
|
|
p = int(query.get("p", ["1"])[0])
|
|
except (ValueError, IndexError):
|
|
p = 1
|
|
return max(1, p)
|
|
|
|
def _page_nav(self, page, total, base_url):
|
|
if total <= PER_PAGE:
|
|
return ""
|
|
total_pages = (total + PER_PAGE - 1) // PER_PAGE
|
|
sep = "&" if "?" in base_url else "?"
|
|
parts = []
|
|
if page > 1:
|
|
parts.append(f'<a href="{base_url}{sep}p={page - 1}">« prev</a>')
|
|
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>{" | ".join(parts)}</p>'
|
|
|
|
def _now(self):
|
|
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
def _time_ago(self, ts):
|
|
try:
|
|
dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S")
|
|
except (ValueError, TypeError):
|
|
return ts
|
|
delta = datetime.now() - dt
|
|
if delta.days > 365:
|
|
return f"{delta.days // 365}y ago"
|
|
if delta.days > 30:
|
|
return f"{delta.days // 30}mo ago"
|
|
if delta.days > 0:
|
|
return f"{delta.days}d ago"
|
|
if delta.seconds >= 3600:
|
|
return f"{delta.seconds // 3600}h ago"
|
|
if delta.seconds >= 60:
|
|
return f"{delta.seconds // 60}m ago"
|
|
return "just now"
|
|
|
|
def _is_new(self, ts):
|
|
try:
|
|
dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S")
|
|
except (ValueError, TypeError):
|
|
return False
|
|
return (datetime.now() - dt).total_seconds() < RECENT_SECONDS
|
|
|
|
def _get_subscribed_topics(self):
|
|
raw = self.fdb.get_setting("topic_subscriptions", "")
|
|
return [t.strip().lower() for t in raw.split(",") if t.strip()]
|
|
|
|
def _get_subscribed_topics_str(self):
|
|
raw = self.fdb.get_setting("topic_subscriptions", "")
|
|
return raw
|
|
|
|
def _blocked_instances(self):
|
|
raw = self.fdb.get_setting("blocked_instances", "")
|
|
return set(h.strip() for h in raw.split(",") if h.strip())
|
|
|
|
def _retracted_threads(self):
|
|
t, p = self.fdb.get_retracted_ids()
|
|
return t
|
|
|
|
def _muted_threads(self):
|
|
raw = self.fdb.get_setting("muted_threads", "")
|
|
return set(h.strip() for h in raw.split(",") if h.strip())
|
|
|
|
def _keyword_filters(self):
|
|
raw = self.fdb.get_setting("keyword_filters", "")
|
|
return [k.strip().lower() for k in raw.split(",") if k.strip()]
|
|
|
|
def _passes_filters(self, thread):
|
|
blocked = self._blocked_instances()
|
|
if thread["author_instance"] in blocked:
|
|
return False
|
|
keywords = self._keyword_filters()
|
|
if keywords:
|
|
text = (thread["title"] + " " + thread["body"]).lower()
|
|
if any(k in text for k in keywords):
|
|
return False
|
|
return True
|
|
|
|
# --- Routes ---
|
|
|
|
def _status_bar(self):
|
|
topics = self._get_subscribed_topics()
|
|
topics_str = ", ".join(topics) if topics else "everything"
|
|
peer_count = len(self.fdb.get_synced_instances())
|
|
filter_count = self.fdb.get_peer_filter_count()
|
|
return (
|
|
f'<div style="font-size:0.85rem;color:#606060">'
|
|
f'subscribed: {esc(topics_str)}'
|
|
f' · {peer_count} peers'
|
|
f' · {filter_count} filters'
|
|
f'</div>'
|
|
)
|
|
|
|
def handle_list(self, query):
|
|
page = self._paginate(query)
|
|
tag = unquote(query.get("tag", [""])[0]).strip()
|
|
search = query.get("q", [""])[0].strip()
|
|
show_muted = query.get("muted", [""])[0].strip() == "1"
|
|
rows, total = self.fdb.get_threads(page=page, per_page=PER_PAGE, tag=tag, search=search)
|
|
muted = self._muted_threads()
|
|
retracted = self._retracted_threads()
|
|
new_count = 0
|
|
items = ""
|
|
for r in rows:
|
|
if r["id"] in retracted:
|
|
continue
|
|
if not self._passes_filters(r):
|
|
continue
|
|
is_muted = r["id"] in muted
|
|
if is_muted and not show_muted:
|
|
continue
|
|
if self._is_new(r["created_at"]):
|
|
new_count += 1
|
|
badge = "share" if r["url"] else "request"
|
|
mute_label = " [muted]" if is_muted else ""
|
|
tags_html = ""
|
|
if r["tags"]:
|
|
tag_links = " ".join(
|
|
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'<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 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'</div>'
|
|
)
|
|
if not items:
|
|
items = "<p>no threads yet.</p>"
|
|
new_label = f" ({new_count} new)" if new_count else ""
|
|
search_form = (
|
|
f'<form method="get" action="/forum">'
|
|
f'<input name="q" placeholder="search" value="{esc(search)}">'
|
|
f'</form>'
|
|
)
|
|
tag_label = f' — {esc(tag)}' if tag else ""
|
|
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"<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"<br>"
|
|
f'{search_form}'
|
|
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"<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><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'<a href="/forum">back</a>'
|
|
)
|
|
|
|
def handle_new_submit(self, body):
|
|
title = body.get("title", [""])[0].strip()
|
|
url = body.get("url", [""])[0].strip()
|
|
body_text = body.get("body", [""])[0].strip()
|
|
tags = body.get("tags", [""])[0].strip()
|
|
if not title:
|
|
return self.handle_new_form("Title is required.")
|
|
if len(title) > MAX_TITLE_LENGTH:
|
|
return self.handle_new_form(f"Title too long (max {MAX_TITLE_LENGTH} characters).")
|
|
if len(body_text) > MAX_BODY_LENGTH:
|
|
return self.handle_new_form(f"Body too long (max {MAX_BODY_LENGTH} characters).")
|
|
thread_id = secrets.token_hex(16)
|
|
author_instance = self.identity.hash.hex() if self.identity else "local"
|
|
author_name = self.site_name
|
|
now = self._now()
|
|
self.fdb.create_thread(thread_id, title, url, body_text, tags, author_instance, author_name, now)
|
|
return self._redirect(f"/forum/t/{thread_id}")
|
|
|
|
def handle_thread(self, thread_id, query=None):
|
|
thread = self.fdb.get_thread(thread_id)
|
|
if not thread or not self._passes_filters(thread):
|
|
return self._error(404)
|
|
_, retracted_posts = self.fdb.get_retracted_ids()
|
|
posts = [p for p in self.fdb.get_posts(thread_id)
|
|
if p["author_instance"] not in self._blocked_instances()
|
|
and p["id"] not in retracted_posts]
|
|
muted = self._muted_threads()
|
|
is_muted = thread["id"] in muted
|
|
instance_hash = self.identity.hash.hex() if self.identity else "local"
|
|
has_upvoted = self.fdb.has_upvoted(thread_id, instance_hash)
|
|
|
|
badge = "share" if thread["url"] else "request"
|
|
url_html = ""
|
|
if thread["url"]:
|
|
url_html = (
|
|
f'<p><a href="{esc(thread["url"])}" rel="noreferrer noopener">{esc(thread["url"])}</a>'
|
|
f' (<a href="/add?url={esc(thread["url"])}">+ save</a>)</p>'
|
|
)
|
|
tags_html = ""
|
|
if thread["tags"]:
|
|
tag_links = " ".join(
|
|
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>'
|
|
|
|
body_html = f"<p>{esc(thread['body'])}</p>" if thread["body"] else ""
|
|
|
|
mute_label = "unmute" if is_muted else "mute"
|
|
mute_href = f'/forum/unmute/{thread["id"]}' if is_muted else f'/forum/mute/{thread["id"]}'
|
|
|
|
posts_html = ""
|
|
for p in posts:
|
|
save_links = ""
|
|
for word in p["body"].split():
|
|
w = word.strip().strip(",.!?;:")
|
|
if w.startswith(("http://", "https://")):
|
|
save_links += f' <a href="/add?url={esc(w)}">+ save</a>'
|
|
parent_ref = ""
|
|
if p["parent_id"]:
|
|
parent_ref = f' <a href="#post-{esc(p["parent_id"])}">↪ reply</a>'
|
|
posts_html += (
|
|
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}'
|
|
f'{" · " + self._post_retract_link(thread["id"], p["id"]) if p["author_instance"] == instance_hash else ""}'
|
|
f'</div>'
|
|
f'<p>{esc(p["body"])}</p>'
|
|
f'{save_links}'
|
|
f'</div>'
|
|
)
|
|
|
|
reply_form = (
|
|
f'<form method="post" action="/forum/t/{thread["id"]}/reply">'
|
|
f'{self._csrf_field()}'
|
|
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"<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'</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>"
|
|
f"{reply_form}<br>"
|
|
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"<p>Update your thread.</p>"
|
|
f'<form method="post" action="/forum/t/{thread_id}/edit">'
|
|
f'{self._csrf_field()}'
|
|
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>"
|
|
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.")
|
|
if len(title) > MAX_TITLE_LENGTH:
|
|
return self.handle_edit_form(thread_id, f"Title too long (max {MAX_TITLE_LENGTH} characters).")
|
|
url = body.get("url", [""])[0].strip()
|
|
body_text = body.get("body", [""])[0].strip()
|
|
if len(body_text) > MAX_BODY_LENGTH:
|
|
return self.handle_edit_form(thread_id, f"Body too long (max {MAX_BODY_LENGTH} characters).")
|
|
tags = body.get("tags", [""])[0].strip()
|
|
now = self._now()
|
|
self.fdb.update_thread(thread_id, title, url, body_text, tags, now)
|
|
return self._redirect(f"/forum/t/{thread_id}")
|
|
|
|
def handle_reply(self, thread_id, body):
|
|
body_text = body.get("body", [""])[0].strip()
|
|
if not body_text:
|
|
return self._redirect(f"/forum/t/{thread_id}")
|
|
if len(body_text) > MAX_BODY_LENGTH:
|
|
return self._respond(f"<p>Body too long (max {MAX_BODY_LENGTH} characters). <a href=\"/forum/t/{esc(thread_id)}\">back</a></p>")
|
|
parent_id = body.get("parent_id", [""])[0].strip()
|
|
author_instance = self.identity.hash.hex() if self.identity else "local"
|
|
author_name = self.site_name
|
|
post_id = secrets.token_hex(16)
|
|
now = self._now()
|
|
self.fdb.create_post(post_id, thread_id, parent_id, body_text, author_instance, author_name, now)
|
|
return self._redirect(f"/forum/t/{thread_id}")
|
|
|
|
def handle_upvote(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"
|
|
self.fdb.toggle_upvote(thread_id, instance_hash)
|
|
return self._redirect(f"/forum/t/{thread_id}")
|
|
|
|
def handle_mute(self, thread_id):
|
|
muted = self._muted_threads()
|
|
muted.add(thread_id)
|
|
self.fdb.set_setting("muted_threads", ",".join(muted))
|
|
return self._redirect(f"/forum")
|
|
|
|
def handle_unmute(self, thread_id):
|
|
muted = self._muted_threads()
|
|
muted.discard(thread_id)
|
|
self.fdb.set_setting("muted_threads", ",".join(muted))
|
|
return self._redirect(f"/forum/t/{thread_id}")
|
|
|
|
def _author_links(self, tid, author_instance, instance_hash):
|
|
links = ""
|
|
if author_instance == instance_hash:
|
|
links += f' · <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 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.append(f"{esc(h[:16])}... — {count} reports{status}")
|
|
return '<p style="font-size:0.85rem;color:#606060">' + "<br>".join(items) + "</p>"
|
|
|
|
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()
|
|
filters = self._keyword_filters()
|
|
filters_str = ", ".join(filters) if filters else ""
|
|
synced = self.fdb.get_synced_instances()
|
|
auto_discover = self.fdb.get_setting("forum_auto_discover", "1")
|
|
auto_discover_checked = " checked" if auto_discover == "1" else ""
|
|
auto_sync = self.fdb.get_setting("forum_auto_sync", "0")
|
|
auto_sync_checked = " checked" if auto_sync == "1" else ""
|
|
retention_days = self.fdb.get_setting("forum_retention_days", "30")
|
|
|
|
blocked_items = ""
|
|
if blocked:
|
|
for h in sorted(blocked):
|
|
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'<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'<input type="submit" value="unblock">'
|
|
f"</form>"
|
|
f'</div>'
|
|
)
|
|
blocked_items = blocked_items
|
|
else:
|
|
blocked_items = "<p>no instances blocked</p>"
|
|
|
|
synced_items = ""
|
|
for s in synced:
|
|
synced_items += (
|
|
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'</div>'
|
|
)
|
|
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"<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"<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'<br><br><input type="submit" value="save subscriptions">'
|
|
f"</form>"
|
|
f"</section>"
|
|
f"<hr>"
|
|
|
|
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">'
|
|
f' days (0 = keep everything)</label><br><br>'
|
|
f'<input type="submit" value="save settings">'
|
|
f"</form>"
|
|
f"</section>"
|
|
f"<hr>"
|
|
|
|
f"<section id=\"network\">"
|
|
f"<h2>network</h2>"
|
|
f"<p>{len(synced)} known peers</p>"
|
|
f'{synced_items}'
|
|
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'<br><br><input type="submit" value="add">'
|
|
f"</form>"
|
|
f"</section>"
|
|
f"<hr>"
|
|
|
|
f"<section id=\"moderation\">"
|
|
f"<h2>moderation</h2>"
|
|
f"<h3>blocked instances</h3>"
|
|
f'{blocked_items}'
|
|
f'<form method="post" action="/forum/block">'
|
|
f'{self._csrf_field()}'
|
|
f'<input name="instance" placeholder="instance hash (32 hex chars)">'
|
|
f'<br><br><input type="submit" value="block">'
|
|
f"</form>"
|
|
f"<h3>peer reports</h3>"
|
|
f'{self._peer_reports_html()}'
|
|
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'<br><br><input type="submit" value="save filters">'
|
|
f"</form>"
|
|
f"</section>"
|
|
|
|
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:
|
|
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))
|
|
self._set_flash(f"Blocked {instance[:16]}...")
|
|
return self._redirect("/forum/moderation")
|
|
|
|
def handle_unblock(self, body):
|
|
instance = body.get("instance", [""])[0].strip()
|
|
blocked = self._blocked_instances()
|
|
blocked.discard(instance)
|
|
self.fdb.set_setting("blocked_instances", ",".join(blocked))
|
|
auto = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
|
|
auto.discard(instance)
|
|
self.fdb.set_setting("auto_blocked_instances", ",".join(auto))
|
|
self.fdb.clear_peer_block(instance)
|
|
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:
|
|
blocked = self._blocked_instances()
|
|
if instance in blocked:
|
|
blocked.discard(instance)
|
|
self.fdb.clear_peer_block(instance)
|
|
else:
|
|
blocked.add(instance)
|
|
self.fdb.set_setting("blocked_instances", ",".join(blocked))
|
|
auto = set(h.strip() for h in self.fdb.get_setting("auto_blocked_instances", "").split(",") if h.strip())
|
|
auto.discard(instance)
|
|
self.fdb.set_setting("auto_blocked_instances", ",".join(auto))
|
|
return self._redirect("/forum")
|
|
|
|
def handle_filters(self, body):
|
|
keywords = body.get("keywords", [""])[0].strip()
|
|
self.fdb.set_setting("keyword_filters", keywords)
|
|
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")
|
|
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:
|
|
self._set_flash("Invalid retention days.")
|
|
return self._redirect("/forum/moderation")
|
|
self.fdb.set_setting("forum_retention_days", str(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")
|
|
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:
|
|
return self._respond("<p>Sync not available.</p>")
|
|
count = self.sync.sync_now()
|
|
msg = f"Synced with {count} instance{'s' if count != 1 else ''}."
|
|
if count == 0:
|
|
msg = "No peers to sync with."
|
|
return self._respond(f"<p>{msg}</p><p><a href=\"/forum\">back to forum</a></p>")
|
|
|
|
def handle_sync_add(self, body):
|
|
instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "")
|
|
name = body.get("name", [""])[0].strip()
|
|
if len(instance) != 32:
|
|
self._set_flash("Invalid instance hash (must be 32 hex chars).")
|
|
return self._redirect("/forum/moderation")
|
|
self.fdb.upsert_synced_instance(instance, name)
|
|
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)
|
|
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)
|
|
self._set_flash("Topic subscriptions saved.")
|
|
return self._redirect("/forum/moderation")
|
|
|
|
# --- Sync endpoint (called over RNS) ---
|
|
|
|
def handle_sync_request(self, data):
|
|
since = data.get("query", {}).get("since", [""])[0] if isinstance(data.get("query"), dict) else ""
|
|
incoming_threads = data.get("threads", [])
|
|
incoming_posts = data.get("posts", [])
|
|
incoming_upvotes = data.get("upvotes", [])
|
|
peer_topics = data.get("my_topics", [])
|
|
peer_tag_cloud = data.get("my_tag_cloud", [])
|
|
peer_bloom_data = data.get("content_bloom")
|
|
peer_tag_bloom_data = data.get("tag_bloom")
|
|
peer_filter_table = data.get("filter_table", {})
|
|
scoped_query_tag = data.get("scoped_query", "")
|
|
|
|
blocked = self._blocked_instances()
|
|
my_topics = self._get_subscribed_topics()
|
|
from_hash = data.get("from_hash", "")
|
|
|
|
# Store peer's tag bloom filter (Architecture B discovery)
|
|
if peer_tag_bloom_data and from_hash:
|
|
self.fdb.store_peer_filter(
|
|
peer_hash=from_hash,
|
|
bloom_bytes=bytes(peer_tag_bloom_data),
|
|
bloom_size=data.get("tag_bloom_size", 2048),
|
|
bloom_hashes=data.get("tag_bloom_hashes", 3),
|
|
tag_count=len(peer_topics),
|
|
)
|
|
|
|
# Merge filter table gossip (transitive peer discovery)
|
|
if peer_filter_table and from_hash:
|
|
for ph, entry in peer_filter_table.items():
|
|
if isinstance(entry, dict):
|
|
bb = entry.get("bloom_bytes")
|
|
if bb:
|
|
self.fdb.store_peer_filter(
|
|
peer_hash=ph,
|
|
bloom_bytes=bytes(bb) if isinstance(bb, list) else bb,
|
|
bloom_size=entry.get("bloom_size", 2048),
|
|
bloom_hashes=entry.get("bloom_hashes", 3),
|
|
tag_count=entry.get("tag_count", 0),
|
|
)
|
|
|
|
# Handle scoped query: find peers whose bloom might contain the queried tag
|
|
scoped_query_results = []
|
|
if scoped_query_tag and from_hash:
|
|
scoped_query_results = self.fdb.get_filtered_peers_by_tag(scoped_query_tag)
|
|
|
|
# Store peer's topics for future routing (backward compat)
|
|
if peer_topics and from_hash:
|
|
self.fdb.set_setting(f"peer_topics_{from_hash}", ",".join(peer_topics))
|
|
if peer_tag_cloud and from_hash:
|
|
self.fdb.set_setting(f"peer_tag_cloud_{from_hash}", json.dumps(peer_tag_cloud))
|
|
|
|
# Build our tag bloom filter to send back
|
|
peer_tag_bs = data.get("tag_bloom_size", 2048)
|
|
peer_tag_bh = data.get("tag_bloom_hashes", 3)
|
|
my_tag_bloom = BloomFilter.from_tags(my_topics or [], peer_tag_bs, peer_tag_bh)
|
|
|
|
# Build our content bloom filter for dedup
|
|
our_existing = set()
|
|
for t in self.fdb.get_threads_by_topics(peer_topics) if peer_topics else []:
|
|
our_existing.add(t["id"])
|
|
our_bloom = BloomFilter.from_items(list(our_existing), data.get("bloom_size", 2048) if data else 2048, data.get("bloom_hashes", 3) if data else 3)
|
|
|
|
# Build filter table gossip from our stored filters
|
|
all_filters = self.fdb.get_all_filters()
|
|
filter_table_gossip = {}
|
|
for f in all_filters[:20]:
|
|
if f["peer_hash"] != from_hash:
|
|
filter_table_gossip[f["peer_hash"]] = {
|
|
"bloom_bytes": list(f["bloom_bytes"]),
|
|
"bloom_size": f["bloom_size"],
|
|
"bloom_hashes": f["bloom_hashes"],
|
|
"tag_count": f["tag_count"],
|
|
}
|
|
|
|
# Parse peer's content bloom for dedup
|
|
peer_bloom = None
|
|
if peer_bloom_data:
|
|
peer_bloom = BloomFilter.from_bytes(
|
|
bytes(peer_bloom_data),
|
|
data.get("bloom_size", 2048),
|
|
data.get("bloom_hashes", 3),
|
|
)
|
|
|
|
# Merge incoming content, filtered by bloom and topics
|
|
if incoming_threads:
|
|
for t in incoming_threads:
|
|
if t.get("author_instance", "") in blocked:
|
|
continue
|
|
if peer_bloom and peer_bloom.might_contain(t["id"]):
|
|
continue
|
|
if not my_topics:
|
|
self.fdb.merge_thread(t)
|
|
else:
|
|
t_tags = [tag.strip().lower() for tag in t.get("tags", "").split(",") if tag.strip()]
|
|
if set(my_topics) & set(t_tags):
|
|
self.fdb.merge_thread(t)
|
|
if incoming_posts:
|
|
for p in incoming_posts:
|
|
if p.get("author_instance", "") in blocked:
|
|
continue
|
|
if peer_bloom and peer_bloom.might_contain(p["id"]):
|
|
continue
|
|
self.fdb.merge_post(p)
|
|
if incoming_upvotes:
|
|
for uv in incoming_upvotes:
|
|
self.fdb.merge_upvote(uv["thread_id"], uv["instance_hash"])
|
|
|
|
incoming_blocks = data.get("blocks", {})
|
|
peer_hash = data.get("peer_hash", "") or 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)
|
|
|
|
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"])
|
|
|
|
if from_hash and from_hash not in blocked:
|
|
self.fdb.add_known_peer(from_hash)
|
|
for peer_hash in data.get("known_peers", []):
|
|
if peer_hash and peer_hash != from_hash and peer_hash not in blocked:
|
|
self.fdb.add_known_peer(peer_hash)
|
|
|
|
my_blocks = list(blocked)
|
|
my_peer_blocks = self.fdb.get_peer_block_list()
|
|
my_tag_cloud = self.fdb.get_tag_cloud(50)
|
|
my_tag_list = [t for t, _ in my_tag_cloud]
|
|
|
|
threads, posts, upvote_threads = [], [], []
|
|
if since:
|
|
if peer_topics:
|
|
rows = self.fdb.get_threads_by_topics(peer_topics, since=since)
|
|
threads = [dict(r) for r in rows]
|
|
tids = [r["id"] for r in rows]
|
|
posts = [dict(p) for p in self.fdb.get_posts_by_thread_ids(tids)]
|
|
uv_rows = self.fdb.get_new_upvotes_since(since, tids)
|
|
upvote_threads = uv_rows
|
|
else:
|
|
ts, posts_list, up_list = self.fdb.get_new_content(since)
|
|
threads = [dict(r) for r in ts]
|
|
posts = [dict(r) for r in posts_list]
|
|
upvote_threads = up_list
|
|
|
|
known_peers = [h for h in self.fdb.get_all_known_hashes() if h != from_hash]
|
|
peer_topics_map = {}
|
|
for ph in known_peers[:100]:
|
|
pt = self.fdb.get_setting(f"peer_topics_{ph}", "")
|
|
if pt:
|
|
peer_topics_map[ph] = [t.strip() for t in pt.split(",") if t.strip()]
|
|
|
|
retracted = [{"id": cid, "type": ct, "author": ai, "at": ra}
|
|
for cid, ct, ai, ra in self.fdb.get_raw_retractions()]
|
|
|
|
return {
|
|
"status": 200,
|
|
"content_type": "application/json",
|
|
"body": json.dumps({
|
|
"threads": threads,
|
|
"posts": posts,
|
|
"upvote_threads": upvote_threads,
|
|
"blocks": {"mine": my_blocks, "peers": my_peer_blocks},
|
|
"retractions": retracted,
|
|
"known_peers": known_peers,
|
|
"peer_topics": my_tag_list,
|
|
"peer_tag_cloud": my_tag_cloud,
|
|
"content_bloom": list(our_bloom.bytes),
|
|
"bloom_size": data.get("bloom_size", 2048),
|
|
"bloom_hashes": data.get("bloom_hashes", 3),
|
|
"peer_topics_map": peer_topics_map,
|
|
# Architecture B additions
|
|
"tag_bloom": list(my_tag_bloom.bytes),
|
|
"tag_bloom_size": peer_tag_bs,
|
|
"tag_bloom_hashes": peer_tag_bh,
|
|
"filter_table": filter_table_gossip,
|
|
"scoped_query_results": scoped_query_results,
|
|
}),
|
|
"headers": {},
|
|
}
|
|
|
|
def handle_sync_add_instance(self, body):
|
|
"""Add instance for sync (from moderation page action)."""
|
|
return self.handle_sync_add(body)
|
|
|
|
# --- Router ---
|
|
|
|
def _with_csrf(self, resp, csrf_token):
|
|
resp.setdefault("headers", {})
|
|
if resp.get("content_type", "").startswith("text/html"):
|
|
resp["headers"]["Set-Cookie"] = (
|
|
f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/forum"
|
|
)
|
|
return resp
|
|
|
|
def handle(self, method, path, query, body, cookies=None):
|
|
csrf_token = (cookies or {}).get("_csrf", "")
|
|
if not csrf_token:
|
|
csrf_token = secrets.token_hex(32)
|
|
self._request_local.csrf_token = csrf_token
|
|
|
|
if not path.startswith("/forum"):
|
|
return self._with_csrf(self._error(404), csrf_token)
|
|
|
|
sub = path[len("/forum"):]
|
|
|
|
if method == "GET":
|
|
if sub == "" or sub == "/":
|
|
return self._with_csrf(self.handle_list(query), csrf_token)
|
|
elif sub == "/new":
|
|
return self._with_csrf(self.handle_new_form(), csrf_token)
|
|
elif sub == "/moderation":
|
|
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"):
|
|
return self._with_csrf(self.handle_upvote(tid[:-7], {}), csrf_token)
|
|
elif tid.endswith("/edit"):
|
|
return self._with_csrf(self.handle_edit_form(tid[:-5]), csrf_token)
|
|
return self._with_csrf(self.handle_thread(tid, query), csrf_token)
|
|
elif sub.startswith("/retract/"):
|
|
rest = sub[9:]
|
|
if "/post/" in rest:
|
|
tid, pid = rest.split("/post/", 1)
|
|
return self._with_csrf(self.handle_retract_post(pid, tid), csrf_token)
|
|
return self._with_csrf(self.handle_retract_thread(rest), csrf_token)
|
|
elif sub.startswith("/mute/"):
|
|
return self._with_csrf(self.handle_mute(sub[6:]), csrf_token)
|
|
elif sub.startswith("/unmute/"):
|
|
return self._with_csrf(self.handle_unmute(sub[8:]), csrf_token)
|
|
elif sub.startswith("/blockhash/"):
|
|
return self._with_csrf(self.handle_block_hash(sub[11:]), csrf_token)
|
|
elif sub == "/sync/now":
|
|
return self._with_csrf(self.handle_sync_now(), csrf_token)
|
|
elif method == "POST":
|
|
if not self._check_csrf(body):
|
|
return self._with_csrf(
|
|
self._respond("<h1>403 Forbidden</h1>", status=403), csrf_token
|
|
)
|
|
if sub == "/new":
|
|
return self._with_csrf(self.handle_new_submit(body), csrf_token)
|
|
elif sub.startswith("/t/"):
|
|
rest = sub[3:]
|
|
if rest.endswith("/edit"):
|
|
tid = rest[:-5]
|
|
return self._with_csrf(self.handle_edit_submit(tid, body), csrf_token)
|
|
if "/reply" in rest:
|
|
tid = rest.split("/reply")[0]
|
|
return self._with_csrf(self.handle_reply(tid, body), csrf_token)
|
|
elif rest.endswith("/upvote"):
|
|
tid = rest[:-7]
|
|
return self._with_csrf(self.handle_upvote(tid, body), 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 == "/block":
|
|
return self._with_csrf(self.handle_block(body), csrf_token)
|
|
elif sub == "/unblock":
|
|
return self._with_csrf(self.handle_unblock(body), csrf_token)
|
|
elif sub == "/filters":
|
|
return self._with_csrf(self.handle_filters(body), csrf_token)
|
|
elif sub == "/sync/add":
|
|
return self._with_csrf(self.handle_sync_add(body), csrf_token)
|
|
elif sub == "/unsync":
|
|
return self._with_csrf(self.handle_unsync(body), csrf_token)
|
|
elif sub == "/auto_discover":
|
|
return self._with_csrf(self.handle_auto_discover(body), csrf_token)
|
|
elif sub == "/storage":
|
|
return self._with_csrf(self.handle_storage(body), csrf_token)
|
|
elif sub == "/auto_sync":
|
|
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)
|
|
|
|
def handle_sync(self, data):
|
|
"""Entry point for incoming RNS sync requests."""
|
|
return self.handle_sync_request(data)
|