auto-discovery toggle, auto-prune with customizable retention
This commit is contained in:
parent
7ebf35b137
commit
f8f9cb6337
4 changed files with 115 additions and 4 deletions
|
|
@ -429,6 +429,28 @@ def test_announce_handler():
|
||||||
shutil.rmtree(dir_a)
|
shutil.rmtree(dir_a)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_old_content():
|
||||||
|
"""Old threads should be pruned after retention period."""
|
||||||
|
dir_a = tempfile.mkdtemp()
|
||||||
|
try:
|
||||||
|
fdb = ForumDB(dir_a)
|
||||||
|
now = "2026-06-05T12:00:00"
|
||||||
|
old = "2026-01-01T12:00:00"
|
||||||
|
fdb.create_thread("new_t", "New", "", "", "", "aaa", "", now)
|
||||||
|
fdb.create_thread("old_t", "Old", "", "", "", "bbb", "", old)
|
||||||
|
fdb.create_post("old_p", "old_t", "", "old reply", "bbb", "", old)
|
||||||
|
|
||||||
|
# Prune with 30 day retention — old thread is ~5 months old
|
||||||
|
fdb.prune_old_content(30)
|
||||||
|
|
||||||
|
assert fdb.get_thread("new_t") is not None, "new thread should survive"
|
||||||
|
assert fdb.get_thread("old_t") is None, "old thread should be pruned"
|
||||||
|
assert len(list(fdb.get_posts("old_t"))) == 0, "old posts should be pruned"
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(dir_a)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_sync_thread()
|
test_sync_thread()
|
||||||
test_sync_reply()
|
test_sync_reply()
|
||||||
|
|
@ -440,4 +462,5 @@ if __name__ == "__main__":
|
||||||
test_sync_updated_content()
|
test_sync_updated_content()
|
||||||
test_sync_peer_discovery()
|
test_sync_peer_discovery()
|
||||||
test_announce_handler()
|
test_announce_handler()
|
||||||
|
test_prune_old_content()
|
||||||
print("all sync tests passed")
|
print("all sync tests passed")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
FORUM_DB = "forum.db"
|
FORUM_DB = "forum.db"
|
||||||
|
|
||||||
|
|
@ -497,3 +498,28 @@ class ForumDB:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
finally:
|
finally:
|
||||||
self.return_db(db)
|
self.return_db(db)
|
||||||
|
|
||||||
|
def prune_old_content(self, retention_days):
|
||||||
|
"""Delete threads and posts older than retention_days."""
|
||||||
|
db = self.get_db()
|
||||||
|
try:
|
||||||
|
cutoff = (datetime.utcnow() - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
# Delete posts in old threads
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM posts WHERE thread_id IN "
|
||||||
|
"(SELECT id FROM threads WHERE updated_at < ?)",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
# Delete orphaned posts (thread already deleted)
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM posts WHERE thread_id NOT IN (SELECT id FROM threads)"
|
||||||
|
)
|
||||||
|
# Delete old threads
|
||||||
|
db.execute("DELETE FROM threads WHERE updated_at < ?", (cutoff,))
|
||||||
|
# Clean up orphaned upvotes
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM upvotes WHERE thread_id NOT IN (SELECT id FROM threads)"
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
self.return_db(db)
|
||||||
|
|
|
||||||
|
|
@ -484,10 +484,28 @@ class ForumHandlers:
|
||||||
)
|
)
|
||||||
synced_items = f"<ul>{synced_items}</ul>" if synced_items else "<p>No instances synced yet.</p>"
|
synced_items = f"<ul>{synced_items}</ul>" if synced_items else "<p>No instances synced yet.</p>"
|
||||||
|
|
||||||
|
auto_discover = self.fdb.get_setting("forum_auto_discover", "1")
|
||||||
|
auto_discover_checked = " checked" if auto_discover == "1" else ""
|
||||||
|
retention_days = self.fdb.get_setting("forum_retention_days", "30")
|
||||||
|
|
||||||
return self._respond(
|
return self._respond(
|
||||||
f"<h1>forum moderation</h1>"
|
f"<h1>forum moderation</h1>"
|
||||||
f"<p>{msg}</p>"
|
f"<p>{msg}</p>"
|
||||||
f"<p><em>Forum instances on the mesh are discovered and synced automatically.</em></p>"
|
f"<h2>auto-discovery</h2>"
|
||||||
|
f'<form method="post" action="/forum/auto_discover">'
|
||||||
|
f'{self._csrf_field()}'
|
||||||
|
f'<label><input type="checkbox" name="enabled" value="1"{auto_discover_checked}>'
|
||||||
|
f" automatically discover and sync with other forum instances on the mesh</label><br><br>"
|
||||||
|
f'<button>save</button>'
|
||||||
|
f"</form>"
|
||||||
|
f"<h2>storage</h2>"
|
||||||
|
f'<form method="post" action="/forum/storage">'
|
||||||
|
f'{self._csrf_field()}'
|
||||||
|
f'<label>Keep threads for '
|
||||||
|
f'<input name="retention_days" value="{esc(retention_days)}" size="4"> days</label>'
|
||||||
|
f"<br><small>Older threads are pruned automatically (default: 30). Set to 0 to keep everything.</small><br><br>"
|
||||||
|
f'<button>save</button>'
|
||||||
|
f"</form>"
|
||||||
f"<h2>blocked instances</h2>"
|
f"<h2>blocked instances</h2>"
|
||||||
f"{blocked_items}"
|
f"{blocked_items}"
|
||||||
f'<form method="post" action="/forum/block">'
|
f'<form method="post" action="/forum/block">'
|
||||||
|
|
@ -556,6 +574,22 @@ class ForumHandlers:
|
||||||
self.fdb.set_setting("keyword_filters", keywords)
|
self.fdb.set_setting("keyword_filters", keywords)
|
||||||
return self.handle_moderation("Filters saved.")
|
return self.handle_moderation("Filters saved.")
|
||||||
|
|
||||||
|
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'}.")
|
||||||
|
|
||||||
|
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.fdb.set_setting("forum_retention_days", str(days))
|
||||||
|
return self.handle_moderation(f"Storage retention set to {days} days.")
|
||||||
|
|
||||||
def handle_sync_add(self, body):
|
def handle_sync_add(self, body):
|
||||||
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()
|
||||||
|
|
@ -726,6 +760,10 @@ class ForumHandlers:
|
||||||
return self._with_csrf(self.handle_sync_add(body), csrf_token)
|
return self._with_csrf(self.handle_sync_add(body), csrf_token)
|
||||||
elif sub == "/unsync":
|
elif sub == "/unsync":
|
||||||
return self._with_csrf(self.handle_unsync(body), csrf_token)
|
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)
|
||||||
|
|
||||||
return self._with_csrf(self._error(404), csrf_token)
|
return self._with_csrf(self._error(404), csrf_token)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,20 +50,35 @@ class ForumSync:
|
||||||
allow=RNS.Destination.ALLOW_ALL,
|
allow=RNS.Destination.ALLOW_ALL,
|
||||||
)
|
)
|
||||||
self.destination.announce(app_data=FORUM_APP.encode("utf-8"))
|
self.destination.announce(app_data=FORUM_APP.encode("utf-8"))
|
||||||
# Auto-discover other forum instances via announces
|
if self.fdb.get_setting("forum_auto_discover", "1") == "1":
|
||||||
self._announce_handler = _ForumAnnounceHandler(self.fdb, self.identity)
|
self._enable_announce_handler()
|
||||||
RNS.Transport.register_announce_handler(self._announce_handler)
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
|
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._disable_announce_handler()
|
||||||
|
|
||||||
|
def set_auto_discover(self, enabled):
|
||||||
|
self.fdb.set_setting("forum_auto_discover", "1" if enabled else "0")
|
||||||
|
if enabled:
|
||||||
|
self._enable_announce_handler()
|
||||||
|
else:
|
||||||
|
self._disable_announce_handler()
|
||||||
|
|
||||||
|
def _enable_announce_handler(self):
|
||||||
|
if self._announce_handler is None:
|
||||||
|
self._announce_handler = _ForumAnnounceHandler(self.fdb, self.identity)
|
||||||
|
RNS.Transport.register_announce_handler(self._announce_handler)
|
||||||
|
|
||||||
|
def _disable_announce_handler(self):
|
||||||
if self._announce_handler:
|
if self._announce_handler:
|
||||||
try:
|
try:
|
||||||
RNS.Transport.deregister_announce_handler(self._announce_handler)
|
RNS.Transport.deregister_announce_handler(self._announce_handler)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self._announce_handler = None
|
||||||
|
|
||||||
def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
|
def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at):
|
||||||
if remote_identity:
|
if remote_identity:
|
||||||
|
|
@ -71,6 +86,7 @@ class ForumSync:
|
||||||
return self.handlers_ref().handle_sync(data)
|
return self.handlers_ref().handle_sync(data)
|
||||||
|
|
||||||
def _sync_loop(self):
|
def _sync_loop(self):
|
||||||
|
prune_counter = 0
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
instances = self.fdb.get_synced_instances()
|
instances = self.fdb.get_synced_instances()
|
||||||
|
|
@ -83,6 +99,14 @@ class ForumSync:
|
||||||
print(f"[forum] sync error with {inst['instance_hash'][:16]}: {e}")
|
print(f"[forum] sync error with {inst['instance_hash'][:16]}: {e}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
prune_counter += 1
|
||||||
|
if prune_counter >= 6:
|
||||||
|
prune_counter = 0
|
||||||
|
try:
|
||||||
|
days = int(self.fdb.get_setting("forum_retention_days", "30"))
|
||||||
|
self.fdb.prune_old_content(days)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
for _ in range(SYNC_INTERVAL):
|
for _ in range(SYNC_INTERVAL):
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue