diff --git a/tests/test_sync.py b/tests/test_sync.py index 6ad6833..65cb597 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -429,6 +429,28 @@ def test_announce_handler(): 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__": test_sync_thread() test_sync_reply() @@ -440,4 +462,5 @@ if __name__ == "__main__": test_sync_updated_content() test_sync_peer_discovery() test_announce_handler() + test_prune_old_content() print("all sync tests passed") diff --git a/tinyweb_forum/db.py b/tinyweb_forum/db.py index b81c8e7..6ab12de 100644 --- a/tinyweb_forum/db.py +++ b/tinyweb_forum/db.py @@ -1,6 +1,7 @@ import sqlite3 import os import threading +from datetime import datetime, timedelta FORUM_DB = "forum.db" @@ -497,3 +498,28 @@ class ForumDB: ).fetchall() finally: 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) diff --git a/tinyweb_forum/handlers.py b/tinyweb_forum/handlers.py index 72c083f..36fdbef 100644 --- a/tinyweb_forum/handlers.py +++ b/tinyweb_forum/handlers.py @@ -484,10 +484,28 @@ class ForumHandlers: ) synced_items = f"" if synced_items else "

No instances synced yet.

" + 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( f"

forum moderation

" f"

{msg}

" - f"

Forum instances on the mesh are discovered and synced automatically.

" + f"

auto-discovery

" + f'
' + f'{self._csrf_field()}' + f'

" + f'' + f"
" + f"

storage

" + f'
' + f'{self._csrf_field()}' + f'' + f"
Older threads are pruned automatically (default: 30). Set to 0 to keep everything.

" + f'' + f"
" f"

blocked instances

" f"{blocked_items}" f'
' @@ -556,6 +574,22 @@ class ForumHandlers: self.fdb.set_setting("keyword_filters", keywords) 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): instance = body.get("instance", [""])[0].strip().replace("<", "").replace(">", "") name = body.get("name", [""])[0].strip() @@ -726,6 +760,10 @@ class ForumHandlers: 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) return self._with_csrf(self._error(404), csrf_token) diff --git a/tinyweb_forum/sync.py b/tinyweb_forum/sync.py index 098dfc0..440479d 100644 --- a/tinyweb_forum/sync.py +++ b/tinyweb_forum/sync.py @@ -50,20 +50,35 @@ class ForumSync: allow=RNS.Destination.ALLOW_ALL, ) self.destination.announce(app_data=FORUM_APP.encode("utf-8")) - # Auto-discover other forum instances via announces - self._announce_handler = _ForumAnnounceHandler(self.fdb, self.identity) - RNS.Transport.register_announce_handler(self._announce_handler) + if self.fdb.get_setting("forum_auto_discover", "1") == "1": + self._enable_announce_handler() self._running = True self._thread = threading.Thread(target=self._sync_loop, daemon=True) self._thread.start() def stop(self): 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: try: RNS.Transport.deregister_announce_handler(self._announce_handler) except Exception: pass + self._announce_handler = None def _rns_handler(self, path, data, request_id, link_id, remote_identity, requested_at): if remote_identity: @@ -71,6 +86,7 @@ class ForumSync: return self.handlers_ref().handle_sync(data) def _sync_loop(self): + prune_counter = 0 while self._running: try: instances = self.fdb.get_synced_instances() @@ -83,6 +99,14 @@ class ForumSync: print(f"[forum] sync error with {inst['instance_hash'][:16]}: {e}") except Exception: 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): if not self._running: return