From 76672aa838be424ba508153dfcb3b6a7a1f0760d Mon Sep 17 00:00:00 2001 From: blankie Date: Wed, 17 Jun 2026 05:03:23 +0000 Subject: [PATCH 1/3] src layout: move core code into src/tinyweb/ package - Moved app.py, db.py, gateway.py, templates.py, embeddings.py, rns_client.py, and handlers/ into src/tinyweb/ - Created root app.py shim (adds src/ to sys.path, imports main) - Created pyproject.toml with setuptools config (where = ["src"]) - Added src/tinyweb/__init__.py - Updated all internal imports to use tinyweb. prefix (73 occurrences) - Removed sys.path.insert hack from conftest.py - Updated Dockerfile: pip install -e /app before running - Updated gateway.py usage message: python -m tinyweb.gateway - Updated README.md gateway usage instructions --- Dockerfile | 2 + README.md | 2 +- app.py | 315 +----------------- conftest.py | 8 +- pyproject.toml | 12 + src/tinyweb/__init__.py | 1 + src/tinyweb/app.py | 312 +++++++++++++++++ db.py => src/tinyweb/db.py | 2 +- embeddings.py => src/tinyweb/embeddings.py | 10 +- gateway.py => src/tinyweb/gateway.py | 2 +- .../tinyweb/handlers}/__init__.py | 10 +- .../tinyweb/handlers}/_helpers.py | 4 +- .../tinyweb/handlers}/customize.py | 12 +- {handlers => src/tinyweb/handlers}/data.py | 6 +- {handlers => src/tinyweb/handlers}/pages.py | 6 +- {handlers => src/tinyweb/handlers}/search.py | 6 +- .../tinyweb/handlers}/subscriptions.py | 8 +- {handlers => src/tinyweb/handlers}/tags.py | 4 +- rns_client.py => src/tinyweb/rns_client.py | 0 templates.py => src/tinyweb/templates.py | 2 +- tests/test_csrf.py | 4 +- tests/test_db_index_url.py | 4 +- tests/test_db_schema.py | 2 +- tests/test_fts_sanitizer.py | 2 +- tests/test_gateway_limits.py | 6 +- tests/test_handlers_pages.py | 4 +- tests/test_handlers_search.py | 2 +- tests/test_handlers_subs.py | 6 +- tests/test_handlers_tags.py | 4 +- tests/test_link_extraction.py | 2 +- tests/test_pagination.py | 2 +- tests/test_regressions.py | 16 +- tests/test_sharing_logic.py | 2 +- tests/test_ssrf.py | 2 +- tests/test_url_cleanup.py | 2 +- 35 files changed, 400 insertions(+), 384 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/tinyweb/__init__.py create mode 100644 src/tinyweb/app.py rename db.py => src/tinyweb/db.py (99%) rename embeddings.py => src/tinyweb/embeddings.py (98%) rename gateway.py => src/tinyweb/gateway.py (99%) rename {handlers => src/tinyweb/handlers}/__init__.py (97%) rename {handlers => src/tinyweb/handlers}/_helpers.py (97%) rename {handlers => src/tinyweb/handlers}/customize.py (98%) rename {handlers => src/tinyweb/handlers}/data.py (95%) rename {handlers => src/tinyweb/handlers}/pages.py (98%) rename {handlers => src/tinyweb/handlers}/search.py (97%) rename {handlers => src/tinyweb/handlers}/subscriptions.py (98%) rename {handlers => src/tinyweb/handlers}/tags.py (96%) rename rns_client.py => src/tinyweb/rns_client.py (100%) rename templates.py => src/tinyweb/templates.py (97%) diff --git a/Dockerfile b/Dockerfile index 3f73263..de57fda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN pip install -e /app + RUN mkdir -p /data ENV PYTHONUNBUFFERED=1 diff --git a/README.md b/README.md index 991c981..c092eda 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ The `/export` page produces a JSON dump of your pages. It's a migration aid — To browse a remote TinyWeb instance without running your own index: ```bash -python gateway.py +python -m tinyweb.gateway ``` This connects over Reticulum and serves the remote instance at `http://localhost:8080`. diff --git a/app.py b/app.py index 035eca0..a5bf38b 100644 --- a/app.py +++ b/app.py @@ -1,312 +1,5 @@ -import os import sys -import time -import threading -import argparse -import RNS -from http.server import HTTPServer, ThreadingHTTPServer - -from db import init_db, get_setting, set_setting -from handlers import dispatch_request -import handlers as handlers_mod -import templates as templates_mod -import gateway -from gateway import GatewayState, GatewayHandler - -IDENTITY_FILE = "tinyweb_identity" -DEFAULT_TRANSPORT_HOST = "rnode.bre.land" -DEFAULT_TRANSPORT_PORT = 4242 -DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb") - - -def get_transport_config(): - host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST) - port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)) - return host, int(port) - - -def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"): - """Find an available port starting from start.""" - import socket - for port in range(start, start + max_attempts): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - s.bind((host, port)) - return port - except OSError: - continue - return start - - -def get_version(): - """Get version from git tag or VERSION file.""" - try: - import subprocess - tag = subprocess.check_output( - ["git", "describe", "--tags", "--abbrev=0"], - stderr=subprocess.DEVNULL, - text=True - ).strip() - if tag.startswith("v"): - return tag[1:] - return tag - except Exception: - version_file = os.path.join(os.path.dirname(__file__), "VERSION") - if os.path.exists(version_file): - with open(version_file) as f: - return f.read().strip() - return "0.0.0" - - -def load_or_create_identity(): - os.makedirs(DATA_DIR, exist_ok=True) - identity_path = os.path.join(DATA_DIR, IDENTITY_FILE) - if os.path.isfile(identity_path): - current = os.stat(identity_path).st_mode & 0o777 - if current != 0o600: - os.chmod(identity_path, 0o600) - return RNS.Identity.from_file(identity_path) - identity = RNS.Identity() - identity.to_file(identity_path) - os.chmod(identity_path, 0o600) - return identity - - -# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface. -# Any other method/path is rejected here — CSRF cannot authenticate mesh callers -# (the attacker controls both the "cookie" and the "form" side of the check), so -# gating by whitelist is the only safe option. -_RNS_ALLOWED = {("GET", "/api/sites")} - - -def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at): - if data is None: - data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""} - method = data.get("method", "GET") - req_path = data.get("path", "/") - if (method, req_path) not in _RNS_ALLOWED: - return { - "status": 403, - "content_type": "text/plain; charset=utf-8", - "body": "Forbidden: this endpoint is not available over Reticulum.", - "headers": {}, - } - return dispatch_request(data) - - -def start_gateway(reticulum, bind_host="127.0.0.1"): - GatewayState.reticulum = reticulum - GatewayState.local_dispatch = dispatch_request - HTTPServer.allow_reuse_address = True - server = ThreadingHTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - - -def _config_settings_match(config_file, desired_host, desired_port): - """Check if existing config transport and LoRa settings match desired values.""" - import configparser - try: - config = configparser.ConfigParser() - config.read(config_file) - # Check TCP transport - tcp_enabled = get_setting("tcp_enabled", "1") == "1" - has_tcp = config.has_section("TCP Transport") - if tcp_enabled != has_tcp: - return False - if tcp_enabled and has_tcp: - if (config.get("TCP Transport", "target_host") != desired_host or - config.get("TCP Transport", "target_port") != str(desired_port)): - return False - # Check LoRa - lora_enabled = get_setting("lora_enabled", "0") == "1" - has_lora = config.has_section("RNode LoRa") - if lora_enabled != has_lora: - return False - if lora_enabled and has_lora: - if config.get("RNode LoRa", "port", fallback="") != get_setting("lora_port", ""): - return False - if config.get("RNode LoRa", "frequency", fallback="") != get_setting("lora_frequency", "867200000"): - return False - return True - except Exception: - pass - return False - - -def ensure_rns_config(config_dir, transport_host=None, transport_port=None): - """Generate a default Reticulum config with internet transport if none exists.""" - if config_dir is None: - config_dir = os.path.expanduser("~/.reticulum") - config_file = os.path.join(config_dir, "config") - if transport_host is None: - transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST) - if transport_port is None: - transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))) - - managed_sentinel = "# managed by tinyweb" - if os.path.exists(config_file): - try: - with open(config_file) as f: - existing = f.read() - except OSError: - existing = "" - if managed_sentinel not in existing: - # User-authored config — don't clobber it. - if not _config_settings_match(config_file, transport_host, transport_port): - print( - f"Warning: {config_file} was not created by tinyweb; " - "leaving it alone. Edit it manually to change transport/LoRa settings." - ) - return - if _config_settings_match(config_file, transport_host, transport_port): - return - - # Build optional interface blocks - tcp_block = "" - if get_setting("tcp_enabled", "1") == "1": - tcp_block = f""" - [[TCP Transport]] - type = TCPClientInterface - enabled = yes - target_host = {transport_host} - target_port = {transport_port} -""" - - lora_block = "" - if get_setting("lora_enabled", "0") == "1": - lora_port = get_setting("lora_port", "") - if lora_port: - lora_frequency = get_setting("lora_frequency", "867200000") - lora_bandwidth = get_setting("lora_bandwidth", "125000") - lora_txpower = get_setting("lora_txpower", "7") - lora_sf = get_setting("lora_sf", "8") - lora_cr = get_setting("lora_cr", "5") - lora_block = f""" - [[RNode LoRa]] - type = RNodeInterface - enabled = yes - port = {lora_port} - frequency = {lora_frequency} - bandwidth = {lora_bandwidth} - txpower = {lora_txpower} - spreadingfactor = {lora_sf} - codingrate = {lora_cr} -""" - - os.makedirs(config_dir, exist_ok=True) - with open(config_file, "w") as f: - f.write(f"""{managed_sentinel} -[reticulum] - enable_transport = False - share_instance = No - -[logging] - loglevel = 4 - -[interfaces] - [[Default Interface]] - type = AutoInterface - enabled = Yes -{tcp_block}{lora_block}""") - print(f"Created Reticulum config at {config_file}") - - -def _preload_embeddings(): - """Pre-load the embedding model and build the HNSW index in background.""" - if get_setting("semantic_search", "0") != "1": - print("Semantic search disabled.") - return - try: - from embeddings import _get_session, _get_reranker, build_index - _get_session() - build_index() - if get_setting("use_reranker", "0") == "1": - _get_reranker() - print("Semantic search ready (with reranker).") - else: - print("Semantic search ready.") - except Exception as e: - print(f"Semantic search unavailable: {e}") - - -def main(): - parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine") - parser.add_argument("--version", "-v", action="store_true", help="Show version") - parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)") - parser.add_argument( - "--bind", "-b", default="127.0.0.1", - help="Address to bind the HTTP gateway to (default: 127.0.0.1). " - "Use 0.0.0.0 to expose to the LAN; note that the web UI has no authentication.", - ) - args = parser.parse_args() - - if args.version: - print(f"TinyWeb {get_version()}") - return - - bind_host = args.bind - port = args.port or 8080 - gateway.GATEWAY_PORT = find_available_port(port, host=bind_host) - - init_db() - transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST) - transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))) - threading.Thread(target=_preload_embeddings, daemon=True).start() - config_dir = os.environ.get("RNS_CONFIG_DIR") - ensure_rns_config(config_dir, transport_host, transport_port) - reticulum = RNS.Reticulum(configdir=config_dir) - identity = load_or_create_identity() - - destination = RNS.Destination( - identity, - RNS.Destination.IN, - RNS.Destination.SINGLE, - gateway.APP_NAME, - *gateway.ASPECTS, - ) - - destination.register_request_handler( - "/tinyweb", - response_generator=rns_request_handler, - allow=RNS.Destination.ALLOW_ALL, - ) - - # Initialize forum plugin if available - forum = None - try: - from tinyweb_forum import ForumPlugin - from db import get_site_name - forum = ForumPlugin(DATA_DIR, identity, reticulum, site_name=get_site_name()) - if get_setting("forum_enabled", "0") == "1": - forum.enable() - templates_mod.FORUM_ENABLED = True - handlers_mod.forum_plugin = forum - print(f"Forum plugin: {'enabled' if forum.is_enabled() else 'available (enable in settings)'}") - except ImportError: - print("Forum plugin not installed (pip install tinyweb[forum])") - except Exception as e: - print(f"Forum plugin error: {e}") - - # Brief delay to ensure all interfaces (especially TCP) are fully ready - time.sleep(2) - destination.announce() - set_setting("dest_hash", destination.hash.hex()) - start_gateway(reticulum, bind_host=bind_host) - - print(f"TinyWeb running!") - if bind_host in ("0.0.0.0", "::"): - print(f"Open http://localhost:{gateway.GATEWAY_PORT} in your browser") - print(f"WARNING: listening on {bind_host} — the web UI has no authentication. " - "Anyone on your network can control this instance.") - else: - print(f"Open http://{bind_host}:{gateway.GATEWAY_PORT} in your browser") - print(f"Destination hash: {RNS.prettyhexrep(destination.hash)} (share this so friends can subscribe)") - - while True: - time.sleep(1) - - -if __name__ == "__main__": - main() +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent / "src")) +from tinyweb.app import main +main() diff --git a/conftest.py b/conftest.py index 9a2f26e..4b5c8be 100644 --- a/conftest.py +++ b/conftest.py @@ -5,15 +5,11 @@ per-test tempfile, `seeded_db` layers sample rows on top, and `csrf_session` primes the thread-local CSRF token that handlers read. """ import socket -import sys -from pathlib import Path import pytest -sys.path.insert(0, str(Path(__file__).parent)) - -import db as db_module -import handlers as handlers_module +import tinyweb.db as db_module +import tinyweb.handlers as handlers_module @pytest.fixture diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6dfbe36 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "tinyweb" +version = "0.1.0" +description = "Personal decentralized search engine" +requires-python = ">=3.10" + +[tool.setuptools.packages.find] +where = ["src"] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.backends._legacy:_Backend" diff --git a/src/tinyweb/__init__.py b/src/tinyweb/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/tinyweb/__init__.py @@ -0,0 +1 @@ + diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py new file mode 100644 index 0000000..ca3b6de --- /dev/null +++ b/src/tinyweb/app.py @@ -0,0 +1,312 @@ +import os +import sys +import time +import threading +import argparse +import RNS +from http.server import HTTPServer, ThreadingHTTPServer + +from tinyweb.db import init_db, get_setting, set_setting +from tinyweb.handlers import dispatch_request +import tinyweb.handlers as handlers_mod +import tinyweb.templates as templates_mod +import tinyweb.gateway +from tinyweb.gateway import GatewayState, GatewayHandler + +IDENTITY_FILE = "tinyweb_identity" +DEFAULT_TRANSPORT_HOST = "rnode.bre.land" +DEFAULT_TRANSPORT_PORT = 4242 +DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb") + + +def get_transport_config(): + host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST) + port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)) + return host, int(port) + + +def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"): + """Find an available port starting from start.""" + import socket + for port in range(start, start + max_attempts): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((host, port)) + return port + except OSError: + continue + return start + + +def get_version(): + """Get version from git tag or VERSION file.""" + try: + import subprocess + tag = subprocess.check_output( + ["git", "describe", "--tags", "--abbrev=0"], + stderr=subprocess.DEVNULL, + text=True + ).strip() + if tag.startswith("v"): + return tag[1:] + return tag + except Exception: + version_file = os.path.join(os.path.dirname(__file__), "VERSION") + if os.path.exists(version_file): + with open(version_file) as f: + return f.read().strip() + return "0.0.0" + + +def load_or_create_identity(): + os.makedirs(DATA_DIR, exist_ok=True) + identity_path = os.path.join(DATA_DIR, IDENTITY_FILE) + if os.path.isfile(identity_path): + current = os.stat(identity_path).st_mode & 0o777 + if current != 0o600: + os.chmod(identity_path, 0o600) + return RNS.Identity.from_file(identity_path) + identity = RNS.Identity() + identity.to_file(identity_path) + os.chmod(identity_path, 0o600) + return identity + + +# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface. +# Any other method/path is rejected here — CSRF cannot authenticate mesh callers +# (the attacker controls both the "cookie" and the "form" side of the check), so +# gating by whitelist is the only safe option. +_RNS_ALLOWED = {("GET", "/api/sites")} + + +def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at): + if data is None: + data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""} + method = data.get("method", "GET") + req_path = data.get("path", "/") + if (method, req_path) not in _RNS_ALLOWED: + return { + "status": 403, + "content_type": "text/plain; charset=utf-8", + "body": "Forbidden: this endpoint is not available over Reticulum.", + "headers": {}, + } + return dispatch_request(data) + + +def start_gateway(reticulum, bind_host="127.0.0.1"): + GatewayState.reticulum = reticulum + GatewayState.local_dispatch = dispatch_request + HTTPServer.allow_reuse_address = True + server = ThreadingHTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + +def _config_settings_match(config_file, desired_host, desired_port): + """Check if existing config transport and LoRa settings match desired values.""" + import configparser + try: + config = configparser.ConfigParser() + config.read(config_file) + # Check TCP transport + tcp_enabled = get_setting("tcp_enabled", "1") == "1" + has_tcp = config.has_section("TCP Transport") + if tcp_enabled != has_tcp: + return False + if tcp_enabled and has_tcp: + if (config.get("TCP Transport", "target_host") != desired_host or + config.get("TCP Transport", "target_port") != str(desired_port)): + return False + # Check LoRa + lora_enabled = get_setting("lora_enabled", "0") == "1" + has_lora = config.has_section("RNode LoRa") + if lora_enabled != has_lora: + return False + if lora_enabled and has_lora: + if config.get("RNode LoRa", "port", fallback="") != get_setting("lora_port", ""): + return False + if config.get("RNode LoRa", "frequency", fallback="") != get_setting("lora_frequency", "867200000"): + return False + return True + except Exception: + pass + return False + + +def ensure_rns_config(config_dir, transport_host=None, transport_port=None): + """Generate a default Reticulum config with internet transport if none exists.""" + if config_dir is None: + config_dir = os.path.expanduser("~/.reticulum") + config_file = os.path.join(config_dir, "config") + if transport_host is None: + transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST) + if transport_port is None: + transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))) + + managed_sentinel = "# managed by tinyweb" + if os.path.exists(config_file): + try: + with open(config_file) as f: + existing = f.read() + except OSError: + existing = "" + if managed_sentinel not in existing: + # User-authored config — don't clobber it. + if not _config_settings_match(config_file, transport_host, transport_port): + print( + f"Warning: {config_file} was not created by tinyweb; " + "leaving it alone. Edit it manually to change transport/LoRa settings." + ) + return + if _config_settings_match(config_file, transport_host, transport_port): + return + + # Build optional interface blocks + tcp_block = "" + if get_setting("tcp_enabled", "1") == "1": + tcp_block = f""" + [[TCP Transport]] + type = TCPClientInterface + enabled = yes + target_host = {transport_host} + target_port = {transport_port} +""" + + lora_block = "" + if get_setting("lora_enabled", "0") == "1": + lora_port = get_setting("lora_port", "") + if lora_port: + lora_frequency = get_setting("lora_frequency", "867200000") + lora_bandwidth = get_setting("lora_bandwidth", "125000") + lora_txpower = get_setting("lora_txpower", "7") + lora_sf = get_setting("lora_sf", "8") + lora_cr = get_setting("lora_cr", "5") + lora_block = f""" + [[RNode LoRa]] + type = RNodeInterface + enabled = yes + port = {lora_port} + frequency = {lora_frequency} + bandwidth = {lora_bandwidth} + txpower = {lora_txpower} + spreadingfactor = {lora_sf} + codingrate = {lora_cr} +""" + + os.makedirs(config_dir, exist_ok=True) + with open(config_file, "w") as f: + f.write(f"""{managed_sentinel} +[reticulum] + enable_transport = False + share_instance = No + +[logging] + loglevel = 4 + +[interfaces] + [[Default Interface]] + type = AutoInterface + enabled = Yes +{tcp_block}{lora_block}""") + print(f"Created Reticulum config at {config_file}") + + +def _preload_embeddings(): + """Pre-load the embedding model and build the HNSW index in background.""" + if get_setting("semantic_search", "0") != "1": + print("Semantic search disabled.") + return + try: + from tinyweb.embeddings import _get_session, _get_reranker, build_index + _get_session() + build_index() + if get_setting("use_reranker", "0") == "1": + _get_reranker() + print("Semantic search ready (with reranker).") + else: + print("Semantic search ready.") + except Exception as e: + print(f"Semantic search unavailable: {e}") + + +def main(): + parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine") + parser.add_argument("--version", "-v", action="store_true", help="Show version") + parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)") + parser.add_argument( + "--bind", "-b", default="127.0.0.1", + help="Address to bind the HTTP gateway to (default: 127.0.0.1). " + "Use 0.0.0.0 to expose to the LAN; note that the web UI has no authentication.", + ) + args = parser.parse_args() + + if args.version: + print(f"TinyWeb {get_version()}") + return + + bind_host = args.bind + port = args.port or 8080 + gateway.GATEWAY_PORT = find_available_port(port, host=bind_host) + + init_db() + transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST) + transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))) + threading.Thread(target=_preload_embeddings, daemon=True).start() + config_dir = os.environ.get("RNS_CONFIG_DIR") + ensure_rns_config(config_dir, transport_host, transport_port) + reticulum = RNS.Reticulum(configdir=config_dir) + identity = load_or_create_identity() + + destination = RNS.Destination( + identity, + RNS.Destination.IN, + RNS.Destination.SINGLE, + gateway.APP_NAME, + *gateway.ASPECTS, + ) + + destination.register_request_handler( + "/tinyweb", + response_generator=rns_request_handler, + allow=RNS.Destination.ALLOW_ALL, + ) + + # Initialize forum plugin if available + forum = None + try: + from tinyweb_forum import ForumPlugin + from tinyweb.db import get_site_name + forum = ForumPlugin(DATA_DIR, identity, reticulum, site_name=get_site_name()) + if get_setting("forum_enabled", "0") == "1": + forum.enable() + templates_mod.FORUM_ENABLED = True + handlers_mod.forum_plugin = forum + print(f"Forum plugin: {'enabled' if forum.is_enabled() else 'available (enable in settings)'}") + except ImportError: + print("Forum plugin not installed (pip install tinyweb[forum])") + except Exception as e: + print(f"Forum plugin error: {e}") + + # Brief delay to ensure all interfaces (especially TCP) are fully ready + time.sleep(2) + destination.announce() + set_setting("dest_hash", destination.hash.hex()) + start_gateway(reticulum, bind_host=bind_host) + + print(f"TinyWeb running!") + if bind_host in ("0.0.0.0", "::"): + print(f"Open http://localhost:{gateway.GATEWAY_PORT} in your browser") + print(f"WARNING: listening on {bind_host} — the web UI has no authentication. " + "Anyone on your network can control this instance.") + else: + print(f"Open http://{bind_host}:{gateway.GATEWAY_PORT} in your browser") + print(f"Destination hash: {RNS.prettyhexrep(destination.hash)} (share this so friends can subscribe)") + + while True: + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/db.py b/src/tinyweb/db.py similarity index 99% rename from db.py rename to src/tinyweb/db.py index 97378d9..d7f269b 100644 --- a/db.py +++ b/src/tinyweb/db.py @@ -440,7 +440,7 @@ def index_url(url, note="", reticulum_dest=""): db.commit() if get_setting("semantic_search", "0") == "1": try: - from embeddings import store_embeddings + from tinyweb.embeddings import store_embeddings store_embeddings(page_id, title, body, db) except Exception: pass # embedding generation is best-effort diff --git a/embeddings.py b/src/tinyweb/embeddings.py similarity index 98% rename from embeddings.py rename to src/tinyweb/embeddings.py index 03f6f13..2aecee4 100644 --- a/embeddings.py +++ b/src/tinyweb/embeddings.py @@ -246,7 +246,7 @@ def embed(texts, is_query=False): def _maybe_compress(embeddings): """Compress embeddings to float16 if compression is enabled.""" try: - from db import get_setting + from tinyweb.db import get_setting if get_setting("compress_embeddings", "0") == "1": return embeddings.astype(np.float16) except Exception: @@ -279,7 +279,7 @@ def build_index(db=None): import hnswlib global _hnsw_index, _hnsw_ids - from db import get_db, return_db + from tinyweb.db import get_db, return_db own_db = db is None if own_db: db = get_db() @@ -428,7 +428,7 @@ def semantic_search(query_text, limit=100, db=None): scores = [1.0 - float(d) for d in distances[0]] # Fetch chunk details from DB - from db import get_db, return_db + from tinyweb.db import get_db, return_db own_db = db is None if own_db: db = get_db() @@ -501,7 +501,7 @@ def hybrid_search(query_text, bm25_ranked_ids, limit=10, db=None, use_reranker=F rerank_ids = all_ids[:20] tail_ids = all_ids[20:30] - from db import get_db, return_db + from tinyweb.db import get_db, return_db own_db = db is None if own_db: db = get_db() @@ -553,7 +553,7 @@ def hybrid_search(query_text, bm25_ranked_ids, limit=10, db=None, use_reranker=F def reindex_all(db=None, progress_callback=None): """Re-embed all pages and regenerate all summaries. Rebuilds HNSW index.""" - from db import get_db, return_db + from tinyweb.db import get_db, return_db own_db = db is None if own_db: db = get_db() diff --git a/gateway.py b/src/tinyweb/gateway.py similarity index 99% rename from gateway.py rename to src/tinyweb/gateway.py index fa4b076..5b292ff 100644 --- a/gateway.py +++ b/src/tinyweb/gateway.py @@ -201,7 +201,7 @@ class GatewayHandler(BaseHTTPRequestHandler): def main(): if len(sys.argv) < 2: - print(f"Usage: python gateway.py ") + print(f"Usage: python -m tinyweb.gateway ") print(f" The destination hash is printed by app.py on startup.") sys.exit(1) diff --git a/handlers/__init__.py b/src/tinyweb/handlers/__init__.py similarity index 97% rename from handlers/__init__.py rename to src/tinyweb/handlers/__init__.py index 228508e..524ae8d 100644 --- a/handlers/__init__.py +++ b/src/tinyweb/handlers/__init__.py @@ -3,10 +3,10 @@ import secrets import threading from urllib.parse import unquote -from db import get_db, return_db, set_setting -import templates as templates_mod -from templates import esc, wrap_page -from rns_client import fetch_remote_sites +from tinyweb.db import get_db, return_db, set_setting +import tinyweb.templates as templates_mod +from tinyweb.templates import esc, wrap_page +from tinyweb.rns_client import fetch_remote_sites from ._helpers import ( _request_local, _get_csrf_token, _csrf_field, _check_csrf, @@ -132,7 +132,7 @@ def _dispatch_inner(data): _set_flash("Template reset to default.") return _redirect("/style") elif path == "/style/vacuum": - from db import vacuum_db + from tinyweb.db import vacuum_db vacuum_db() _set_flash("Database vacuumed.") return _redirect("/style") diff --git a/handlers/_helpers.py b/src/tinyweb/handlers/_helpers.py similarity index 97% rename from handlers/_helpers.py rename to src/tinyweb/handlers/_helpers.py index 2611ce7..c7e8d83 100644 --- a/handlers/_helpers.py +++ b/src/tinyweb/handlers/_helpers.py @@ -3,8 +3,8 @@ import re import secrets import threading -from db import get_db, return_db, get_setting, set_setting -from templates import wrap_page +from tinyweb.db import get_db, return_db, get_setting, set_setting +from tinyweb.templates import wrap_page _request_local = threading.local() diff --git a/handlers/customize.py b/src/tinyweb/handlers/customize.py similarity index 98% rename from handlers/customize.py rename to src/tinyweb/handlers/customize.py index 6b3786d..959476d 100644 --- a/handlers/customize.py +++ b/src/tinyweb/handlers/customize.py @@ -1,6 +1,6 @@ -from db import get_db, return_db, get_setting, set_setting, get_site_name -import templates as templates_mod -from templates import esc, DEFAULT_TEMPLATE +from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name +import tinyweb.templates as templates_mod +from tinyweb.templates import esc, DEFAULT_TEMPLATE from ._helpers import _respond, _redirect, _json_response, _csrf_field, _get_bookmark_token, _request_local from .subscriptions import _count_shared_pages @@ -48,7 +48,7 @@ def handle_style_form(msg="", gateway_host="", scheme="http"): lora_sf = get_setting("lora_sf", "8") lora_cr = get_setting("lora_cr", "5") csrf = _csrf_field() - from handlers import forum_plugin as _fp + from tinyweb.handlers import forum_plugin as _fp if _fp is not None: forum_body = ( f"
" @@ -223,7 +223,7 @@ def handle_style_submit(body, gateway_host="", scheme="http"): forum_enabled = "1" if body.get("forum_enabled") else "0" current_forum = get_setting("forum_enabled", "0") if forum_enabled != current_forum: - from handlers import forum_plugin + from tinyweb.handlers import forum_plugin if forum_enabled == "1" and forum_plugin is None: _set_flash("Forum plugin not installed. Run: pip install tinyweb-forum") return _redirect("/style") @@ -288,7 +288,7 @@ def handle_field_save(body): if not key: return _json_response({"status": "error", "message": "No key provided."}, 400) if key == "forum_enabled": - from handlers import forum_plugin + from tinyweb.handlers import forum_plugin if value == "1" and forum_plugin is None: return _json_response({"status": "error", "message": "Forum plugin not installed."}, 400) if value == "1": diff --git a/handlers/data.py b/src/tinyweb/handlers/data.py similarity index 95% rename from handlers/data.py rename to src/tinyweb/handlers/data.py index d3a714f..c16a65f 100644 --- a/handlers/data.py +++ b/src/tinyweb/handlers/data.py @@ -1,8 +1,8 @@ import json import threading -from db import get_db, return_db, get_setting, set_setting, index_url -from templates import esc +from tinyweb.db import get_db, return_db, get_setting, set_setting, index_url +from tinyweb.templates import esc from ._helpers import _respond, _json_response, _redirect, _csrf_field MAX_EXPORT = 10000 @@ -111,7 +111,7 @@ def handle_reindex_submit(body): def _run(): try: - from embeddings import reindex_all + from tinyweb.embeddings import reindex_all def progress(current, total): set_setting("reindex_progress", f"{current}/{total}") reindex_all(progress_callback=progress) diff --git a/handlers/pages.py b/src/tinyweb/handlers/pages.py similarity index 98% rename from handlers/pages.py rename to src/tinyweb/handlers/pages.py index 0799251..4eb2a12 100644 --- a/handlers/pages.py +++ b/src/tinyweb/handlers/pages.py @@ -3,8 +3,8 @@ import json import secrets from urllib.parse import unquote -from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url -from templates import esc +from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url +from tinyweb.templates import esc from ._helpers import ( _csrf_field, _respond, _redirect, _error, _paginate, _page_nav, _get_page_tags, _set_page_tags, _cleanup_orphaned_tags, @@ -137,7 +137,7 @@ def handle_add_manual_submit(body): if get_setting("semantic_search", "0") == "1": try: - from embeddings import store_embeddings + from tinyweb.embeddings import store_embeddings store_embeddings(page_id, manual_title, manual_desc, db) db.commit() except Exception as e: diff --git a/handlers/search.py b/src/tinyweb/handlers/search.py similarity index 97% rename from handlers/search.py rename to src/tinyweb/handlers/search.py index d9ee7c1..951b4d4 100644 --- a/handlers/search.py +++ b/src/tinyweb/handlers/search.py @@ -1,5 +1,5 @@ -from db import get_db, return_db, get_setting, get_site_name, clean_url -from templates import esc +from tinyweb.db import get_db, return_db, get_setting, get_site_name, clean_url +from tinyweb.templates import esc from ._helpers import _sanitize_fts_query, _paginate, _get_page_tags, _respond, _page_nav, PER_PAGE @@ -31,7 +31,7 @@ def handle_search(query): chunk_snippets = {} if get_setting("semantic_search", "0") == "1": try: - from embeddings import hybrid_search + from tinyweb.embeddings import hybrid_search use_reranker = get_setting("use_reranker", "1") == "1" fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker) fused_ids = [pid for pid, _ in fused] diff --git a/handlers/subscriptions.py b/src/tinyweb/handlers/subscriptions.py similarity index 98% rename from handlers/subscriptions.py rename to src/tinyweb/handlers/subscriptions.py index 97f20b2..0113d07 100644 --- a/handlers/subscriptions.py +++ b/src/tinyweb/handlers/subscriptions.py @@ -1,9 +1,9 @@ import threading from datetime import datetime -from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url -from templates import esc -from rns_client import fetch_remote_sites +from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url +from tinyweb.templates import esc +from tinyweb.rns_client import fetch_remote_sites from ._helpers import ( _get_page_tags, _respond, _redirect, _json_response, _error, _csrf_field, @@ -389,7 +389,7 @@ def _sync_subscription(sub_id): ) if get_setting("semantic_search", "0") == "1": try: - from embeddings import store_remote_embeddings + from tinyweb.embeddings import store_remote_embeddings rp_id = db.execute( "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?", (sub_id, s["url"]), diff --git a/handlers/tags.py b/src/tinyweb/handlers/tags.py similarity index 96% rename from handlers/tags.py rename to src/tinyweb/handlers/tags.py index f0e927d..46189d6 100644 --- a/handlers/tags.py +++ b/src/tinyweb/handlers/tags.py @@ -1,5 +1,5 @@ -from db import get_db, return_db -from templates import esc +from tinyweb.db import get_db, return_db +from tinyweb.templates import esc from ._helpers import _respond, _paginate, _page_nav, _get_page_tags, BROWSE_PER_PAGE diff --git a/rns_client.py b/src/tinyweb/rns_client.py similarity index 100% rename from rns_client.py rename to src/tinyweb/rns_client.py diff --git a/templates.py b/src/tinyweb/templates.py similarity index 97% rename from templates.py rename to src/tinyweb/templates.py index 27a1a6b..8208211 100644 --- a/templates.py +++ b/src/tinyweb/templates.py @@ -1,5 +1,5 @@ import html -from db import get_setting +from tinyweb.db import get_setting FORUM_ENABLED = False diff --git a/tests/test_csrf.py b/tests/test_csrf.py index 43b4487..82f2584 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -4,8 +4,8 @@ Every POST handler calls this to verify the submitted _csrf field matches the token stored in the thread-local (which is seeded from the cookie by `dispatch_request`). Missing or mismatched tokens must fail closed. """ -import handlers as handlers_module -from handlers import _check_csrf, _csrf_field, _get_csrf_token +import tinyweb.handlers as handlers_module +from tinyweb.handlers import _check_csrf, _csrf_field, _get_csrf_token def _set_token(token): diff --git a/tests/test_db_index_url.py b/tests/test_db_index_url.py index 50f73ce..aac60a5 100644 --- a/tests/test_db_index_url.py +++ b/tests/test_db_index_url.py @@ -6,8 +6,8 @@ in sync via triggers, and the connection pool returning clean connections. from unittest.mock import patch from conftest import patch_dns_ok -import db as db_module -from db import get_db, return_db, index_url +import tinyweb.db as db_module +from tinyweb.db import get_db, return_db, index_url def _mock_fetch_page(title="Test Page", body="test body text", links=None, meta=""): diff --git a/tests/test_db_schema.py b/tests/test_db_schema.py index 5a4f77c..4bc6691 100644 --- a/tests/test_db_schema.py +++ b/tests/test_db_schema.py @@ -3,7 +3,7 @@ `init_db` is called unconditionally on startup, so it must be idempotent and create every table/trigger the rest of the app expects. """ -from db import get_db, return_db, init_db, get_setting, set_setting, get_site_name +from tinyweb.db import get_db, return_db, init_db, get_setting, set_setting, get_site_name EXPECTED_TABLES = { diff --git a/tests/test_fts_sanitizer.py b/tests/test_fts_sanitizer.py index ad061da..08afb6c 100644 --- a/tests/test_fts_sanitizer.py +++ b/tests/test_fts_sanitizer.py @@ -6,7 +6,7 @@ could escape the quoting. These tests keep that regression dead. """ import pytest -from handlers import _sanitize_fts_query +from tinyweb.handlers import _sanitize_fts_query def test_empty_query_returns_no_match_token(): diff --git a/tests/test_gateway_limits.py b/tests/test_gateway_limits.py index 6033c3a..a772968 100644 --- a/tests/test_gateway_limits.py +++ b/tests/test_gateway_limits.py @@ -8,8 +8,8 @@ import io import pytest -import app as app_module -from gateway import GatewayHandler, MAX_BODY_SIZE +from tinyweb import app as app_module +from tinyweb.gateway import GatewayHandler, MAX_BODY_SIZE class FakeHeaders: @@ -72,7 +72,7 @@ def test_post_at_size_cap_accepted(): rfile=io.BytesIO(b""), ) # Stub out local_dispatch so _forward doesn't try the network path. - from gateway import GatewayState + from tinyweb.gateway import GatewayState original = GatewayState.local_dispatch GatewayState.local_dispatch = lambda data: { "status": 404, "content_type": "text/plain", "body": "nope", diff --git a/tests/test_handlers_pages.py b/tests/test_handlers_pages.py index ab4704c..bc80ad0 100644 --- a/tests/test_handlers_pages.py +++ b/tests/test_handlers_pages.py @@ -4,8 +4,8 @@ The bulk-delete confirmation flow is a data-loss guard added in commit 8dffd8c — a stray POST without `confirmed=1` must render the confirmation page instead of actually deleting. """ -from db import get_db, return_db -from handlers import ( +from tinyweb.db import get_db, return_db +from tinyweb.handlers import ( handle_bulk_action, handle_edit_form, handle_edit_submit, diff --git a/tests/test_handlers_search.py b/tests/test_handlers_search.py index f7d2f9e..3f4fb14 100644 --- a/tests/test_handlers_search.py +++ b/tests/test_handlers_search.py @@ -1,5 +1,5 @@ """Tests for `handle_search` — the home page + primary user flow.""" -from handlers import handle_search +from tinyweb.handlers import handle_search def test_empty_index_empty_query_shows_welcome(temp_db, csrf_session): diff --git a/tests/test_handlers_subs.py b/tests/test_handlers_subs.py index 93ee97d..c24ea7b 100644 --- a/tests/test_handlers_subs.py +++ b/tests/test_handlers_subs.py @@ -6,9 +6,9 @@ available and falls back to a live fetch otherwise. """ from unittest.mock import patch -import handlers as handlers_module -from db import get_db, return_db -from handlers import handle_subscription_add, handle_subscription_browse +import tinyweb.handlers as handlers_module +from tinyweb.db import get_db, return_db +from tinyweb.handlers import handle_subscription_add, handle_subscription_browse VALID_HASH = "a" * 32 diff --git a/tests/test_handlers_tags.py b/tests/test_handlers_tags.py index 7ec8f05..3afdad7 100644 --- a/tests/test_handlers_tags.py +++ b/tests/test_handlers_tags.py @@ -4,8 +4,8 @@ Tags are stored via a join table, so orphaned rows in `tags` can accumulate if `_cleanup_orphaned_tags` isn't called after deletion/retagging. Tag counts shown in the UI rely on this being right. """ -from db import get_db, return_db -from handlers import ( +from tinyweb.db import get_db, return_db +from tinyweb.handlers import ( _cleanup_orphaned_tags, _get_page_tags, _set_page_tags, diff --git a/tests/test_link_extraction.py b/tests/test_link_extraction.py index 2d8c741..0baba34 100644 --- a/tests/test_link_extraction.py +++ b/tests/test_link_extraction.py @@ -7,7 +7,7 @@ skip Wikipedia special pages, resolve relatives via urljoin. from unittest.mock import patch from conftest import patch_dns_ok -import db as db_module +import tinyweb.db as db_module class FakeResponse: diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 05077e0..6b6f727 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,5 +1,5 @@ """Tests for `_paginate` and `_page_nav`.""" -from handlers import _paginate, _page_nav, PER_PAGE +from tinyweb.handlers import _paginate, _page_nav, PER_PAGE def test_paginate_default_is_one(): diff --git a/tests/test_regressions.py b/tests/test_regressions.py index f8a5df7..eeab752 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -14,12 +14,12 @@ from unittest.mock import patch import pytest -import app as app_module -import db as db_module -import handlers as handlers_module +from tinyweb import app as app_module +import tinyweb.db as db_module +import tinyweb.handlers as handlers_module from conftest import patch_dns_fail, patch_dns_ok -from db import clean_url -from handlers import _sanitize_fts_query, handle_bulk_action +from tinyweb.db import clean_url +from tinyweb.handlers import _sanitize_fts_query, handle_bulk_action def test_6ffd38d_clean_url_preserves_www_when_bare_domain_fails(monkeypatch): @@ -47,7 +47,7 @@ def test_1bc695f_fts_sanitizer_drops_operator_words(op): def test_1bc695f_gateway_rejects_oversize_body(): """1bc695f: 16 MiB body-size cap prevents memory-exhaustion DoS.""" from tests.test_gateway_limits import FakeGatewayHandler - from gateway import MAX_BODY_SIZE + from tinyweb.gateway import MAX_BODY_SIZE h = FakeGatewayHandler( path="/add", method="POST", headers={"Content-Length": str(MAX_BODY_SIZE + 1)}, @@ -70,7 +70,7 @@ def test_1bc695f_mesh_rejects_non_whitelisted_paths(): def test_1bc695f_pool_returns_clean_connection(temp_db, monkeypatch): """1bc695f: uncommitted transactions on a pooled connection used to leak into the next consumer.""" - from db import get_db, return_db + from tinyweb.db import get_db, return_db db = get_db() db.execute( "INSERT INTO pages (url, title, body) VALUES (?, ?, ?)", @@ -88,7 +88,7 @@ def test_1bc695f_pool_returns_clean_connection(temp_db, monkeypatch): def test_8dffd8c_bulk_delete_requires_confirmation(seeded_db, csrf_session): """8dffd8c: bulk delete without confirmed=1 must render a confirm page instead of deleting — the JS confirm on /pages is a first-line filter only.""" - from db import get_db, return_db + from tinyweb.db import get_db, return_db db = get_db() try: pid = db.execute("SELECT id FROM pages LIMIT 1").fetchone()["id"] diff --git a/tests/test_sharing_logic.py b/tests/test_sharing_logic.py index c9c06d4..36dca46 100644 --- a/tests/test_sharing_logic.py +++ b/tests/test_sharing_logic.py @@ -6,7 +6,7 @@ hiding pages the user meant to share — both are worth a regression net. """ import pytest -from handlers import _page_is_shared +from tinyweb.handlers import _page_is_shared @pytest.mark.parametrize("mode", ["exclude_private", "require_public"]) diff --git a/tests/test_ssrf.py b/tests/test_ssrf.py index 807f9bd..31eb132 100644 --- a/tests/test_ssrf.py +++ b/tests/test_ssrf.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest -from db import _validate_url_target +from tinyweb.db import _validate_url_target def _mock_getaddrinfo(address): diff --git a/tests/test_url_cleanup.py b/tests/test_url_cleanup.py index 1eef72b..8ade28b 100644 --- a/tests/test_url_cleanup.py +++ b/tests/test_url_cleanup.py @@ -6,7 +6,7 @@ this function can silently cause duplicate rows or mask legitimate saves. import pytest from conftest import patch_dns_ok, patch_dns_fail -from db import clean_url, TRACKING_PARAMS +from tinyweb.db import clean_url, TRACKING_PARAMS def test_strips_fragment(monkeypatch): From b07ca37663545ace1dc4730d4a5abdf1e7dc0d9b Mon Sep 17 00:00:00 2001 From: blankie Date: Wed, 17 Jun 2026 05:16:34 +0000 Subject: [PATCH 2/3] readme: update project structure to reflect src layout --- README.md | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c092eda..b38ae0e 100644 --- a/README.md +++ b/README.md @@ -215,21 +215,26 @@ For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyw ## Project structure ``` -app.py — Entry point: boots Reticulum, starts HTTP gateway -gateway.py — HTTP-to-RNS bridge (local or remote dispatch) -handlers/ — Route dispatcher and request handlers - __init__.py — Dispatch logic + re-exports - _helpers.py — CSRF, FTS sanitizer, pagination, response builders - search.py — Search (BM25, hybrid, trusted/remote results) - pages.py — Add/edit/delete/bulk/bookmark handlers - subscriptions.py — Sync, sharing, API, subscription CRUD - customize.py — Settings form, about page - tags.py — Tag list and browse - data.py — Export, import, semantic reindex -db.py — SQLite database, FTS5, URL fetching, SSRF protection -templates.py — HTML template rendering and escaping -rns_client.py — Reticulum client for fetching remote site lists -themes/ — Saved HTML templates (e.g. kodama.html) +app.py — Entry point (shim, imports from tinyweb.app) +pyproject.toml — Package configuration (src layout) +src/tinyweb/ + __init__.py — Package marker + app.py — Boots Reticulum, starts HTTP gateway + db.py — SQLite database, FTS5, URL fetching, SSRF protection + gateway.py — HTTP-to-RNS bridge (local or remote dispatch) + templates.py — HTML template rendering and escaping + embeddings.py — Semantic search: ONNX, HNSW, reranking + rns_client.py — Reticulum client for fetching remote site lists + handlers/ + __init__.py — Dispatch logic + re-exports + _helpers.py — CSRF, FTS sanitizer, pagination, response builders + search.py — Search (BM25, hybrid, trusted/remote results) + pages.py — Add/edit/delete/bulk/bookmark handlers + subscriptions.py — Sync, sharing, API, subscription CRUD + customize.py — Settings form, about page + tags.py — Tag list and browse + data.py — Export, import, semantic reindex +themes/ — Saved HTML templates (e.g. default.html, junimo.html) ``` ## Security From f27321888d03d67768c6c2a1476614d9fd8364f8 Mon Sep 17 00:00:00 2001 From: blankie Date: Wed, 17 Jun 2026 19:10:33 +0000 Subject: [PATCH 3/3] subscriptions: add forum_enabled toggle for trust circle --- src/tinyweb/db.py | 6 ++++++ src/tinyweb/handlers/__init__.py | 4 ++++ src/tinyweb/handlers/subscriptions.py | 13 +++++++++++++ 3 files changed, 23 insertions(+) diff --git a/src/tinyweb/db.py b/src/tinyweb/db.py index d7f269b..0e5cea2 100644 --- a/src/tinyweb/db.py +++ b/src/tinyweb/db.py @@ -270,6 +270,12 @@ def init_db(): db.execute("ALTER TABLE pages ADD COLUMN reticulum_dest TEXT DEFAULT ''") db.commit() + # Migrate subscriptions: add forum_enabled column + sub_cols = [row[1] for row in db.execute("PRAGMA table_info(subscriptions)").fetchall()] + if "forum_enabled" not in sub_cols: + db.execute("ALTER TABLE subscriptions ADD COLUMN forum_enabled INTEGER DEFAULT 0") + db.commit() + # Chunks table for semantic search embeddings db.execute( "CREATE TABLE IF NOT EXISTS chunks (" diff --git a/src/tinyweb/handlers/__init__.py b/src/tinyweb/handlers/__init__.py index 524ae8d..a09b073 100644 --- a/src/tinyweb/handlers/__init__.py +++ b/src/tinyweb/handlers/__init__.py @@ -29,6 +29,7 @@ from .subscriptions import ( handle_subscriptions, handle_subscription_add, handle_subscription_browse, handle_subscription_pick, _sync_subscription, handle_subscription_sync, handle_subscription_autosync, + handle_subscription_forum, handle_subscription_delete, handle_subscription_syncall, _sync_threads, ) @@ -150,6 +151,9 @@ def _dispatch_inner(data): elif path.startswith("/subscriptions/autosync/"): sid = extract_id("/subscriptions/autosync/") return handle_subscription_autosync(sid) if sid is not None else _error(400) + elif path.startswith("/subscriptions/forum/"): + sid = extract_id("/subscriptions/forum/") + return handle_subscription_forum(sid) if sid is not None else _error(400) elif path.startswith("/subscriptions/delete/"): sid = extract_id("/subscriptions/delete/") return handle_subscription_delete(sid) if sid is not None else _error(400) diff --git a/src/tinyweb/handlers/subscriptions.py b/src/tinyweb/handlers/subscriptions.py index 0113d07..1e0b5b7 100644 --- a/src/tinyweb/handlers/subscriptions.py +++ b/src/tinyweb/handlers/subscriptions.py @@ -146,6 +146,7 @@ def handle_subscriptions(msg=""): for s in subs: sub_id = s["id"] auto_label = "on" if s["auto_sync"] else "off" + forum_label = "on" if s.get("forum_enabled") else "off" last = s["last_sync"] or "never" sync_status = get_setting(f"sync_status_{sub_id}", "") is_syncing = sub_id in _sync_threads and _sync_threads[sub_id].is_alive() @@ -177,6 +178,8 @@ def handle_subscriptions(msg=""): f'{sync_btn}' f'
' f'{_csrf_field()}
' + f'
' + f'{_csrf_field()}
' f'
' f'{_csrf_field()}
' f'' @@ -430,6 +433,16 @@ def handle_subscription_autosync(sub_id): return _redirect("/subscriptions") +def handle_subscription_forum(sub_id): + db = get_db() + try: + db.execute("UPDATE subscriptions SET forum_enabled = 1 - forum_enabled WHERE id = ?", (sub_id,)) + db.commit() + finally: + return_db(db) + return _redirect("/subscriptions") + + def handle_subscription_delete(sub_id): db = get_db() try: