From 468c4453103769c95781d86283f9f83186f97671 Mon Sep 17 00:00:00 2001 From: blankie Date: Thu, 18 Jun 2026 17:15:15 +0000 Subject: [PATCH] rns browser, standalone site server, unified add form - /rns// proxies pages over RNS with link rewriting - fetch_remote_page() in rns_client.py for generic RNS page fetching - mesh_sites table for persisting saved hashes - standalone site_server.py with its own RNS identity - tinyweb-site/index.html: SPA with RSS-aware nav (/rns// prefix) - unified /add form: single input accepts URL or 32-char RNS hash - RNS add indexes into pages table (fetch root page, extract title/desc) - rns: URLs displayed in browse/search, linked to /rns// - expand RNS whitelist: allow GET /, /about, /pages, /tags, /share --- src/tinyweb/app.py | 23 +++-- src/tinyweb/db.py | 7 ++ src/tinyweb/handlers/__init__.py | 14 ++- src/tinyweb/handlers/pages.py | 60 +++++++----- src/tinyweb/handlers/rns.py | 163 +++++++++++++++++++++++++++++++ src/tinyweb/handlers/search.py | 12 ++- src/tinyweb/rns_client.py | 45 +++++---- 7 files changed, 273 insertions(+), 51 deletions(-) create mode 100644 src/tinyweb/handlers/rns.py diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py index b63ed3c..10b756f 100644 --- a/src/tinyweb/app.py +++ b/src/tinyweb/app.py @@ -73,11 +73,22 @@ def load_or_create_identity(): 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")} +# Remote peers on the Reticulum mesh can reach read-only public pages. +# Only GET is allowed; POST is blocked because CSRF cannot authenticate +# mesh callers (the attacker controls both the "cookie" and the "form" side). +_RNS_ALLOWED_GET = { + "/", "/about", "/api/sites", "/share/preview", +} + +_RNS_ALLOWED_PREFIXES = ("/pages", "/tags", "/api/sites", "/rns") + + +def _rns_is_allowed(method, path): + if method != "GET": + return False + if path in _RNS_ALLOWED_GET: + return True + return any(path.startswith(p) for p in _RNS_ALLOWED_PREFIXES) def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at): @@ -85,7 +96,7 @@ def rns_request_handler(path, data, request_id, link_id, remote_identity, reques 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: + if not _rns_is_allowed(method, req_path): return { "status": 403, "content_type": "text/plain; charset=utf-8", diff --git a/src/tinyweb/db.py b/src/tinyweb/db.py index 0e5cea2..c8138dc 100644 --- a/src/tinyweb/db.py +++ b/src/tinyweb/db.py @@ -241,6 +241,13 @@ def init_db(): VALUES (new.id, new.title, new.url, new.note); END; """) + db.execute( + "CREATE TABLE IF NOT EXISTS mesh_sites (" + " hash TEXT PRIMARY KEY," + " name TEXT DEFAULT ''," + " added_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))" + ")" + ) # Migrate old subscriptions table if needed cols = [row[1] for row in db.execute("PRAGMA table_info(subscriptions)").fetchall()] if "url" in cols and "dest_hash" not in cols: diff --git a/src/tinyweb/handlers/__init__.py b/src/tinyweb/handlers/__init__.py index 524ae8d..14a37fd 100644 --- a/src/tinyweb/handlers/__init__.py +++ b/src/tinyweb/handlers/__init__.py @@ -6,7 +6,7 @@ from urllib.parse import unquote 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 tinyweb.rns_client import fetch_remote_sites, fetch_remote_page from ._helpers import ( _request_local, _get_csrf_token, _csrf_field, _check_csrf, @@ -38,6 +38,9 @@ from .data import ( handle_export, handle_import_form, handle_import_submit, handle_reindex_form, handle_reindex_submit, _reindex_thread, ) +from .rns import ( + handle_rns_delete_hash, handle_rns_browse, +) forum_plugin = None @@ -87,6 +90,13 @@ def _dispatch_inner(data): elif path.startswith("/tags/"): tag_name = unquote(path[len("/tags/"):]) return handle_tag_browse(tag_name, query) if tag_name else _error(400) + elif path.startswith("/rns/"): + # /rns// + parts = path[len("/rns/"):].split("/", 1) + dest_hash = parts[0] + if not dest_hash: + return _error(404) + return handle_rns_browse(path, dest_hash) elif path == "/reindex": return handle_reindex_form() elif path == "/api/sites": @@ -155,6 +165,8 @@ def _dispatch_inner(data): return handle_subscription_delete(sid) if sid is not None else _error(400) elif path == "/subscriptions/syncall": return handle_subscription_syncall() + elif path == "/rns/delete": + return handle_rns_delete_hash(body) return _error(404) diff --git a/src/tinyweb/handlers/pages.py b/src/tinyweb/handlers/pages.py index 4eb2a12..9d7f96c 100644 --- a/src/tinyweb/handlers/pages.py +++ b/src/tinyweb/handlers/pages.py @@ -29,11 +29,11 @@ def handle_add_form(msg="", action_type="index", prefill_url=""): ) url_value = f'value="{esc(prefill_url)}" ' if prefill_url else "" return _respond( - f"

add url

" - f"

Add a site to your index

" + f"

add site

" + f"

Add a site to your index — URL or RNS destination hash

" f'
' f'{_csrf_field()}' - f'

' + f'

' f'

' f'
' f'tag: private to exclude from sharing

' @@ -45,27 +45,37 @@ def handle_add_form(msg="", action_type="index", prefill_url=""): def handle_add_submit(body): - input_type = body.get("input_type", ["url"])[0] - url = body.get("url", [""])[0].strip() - reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "") + raw = body.get("url", [""])[0].strip().replace("<", "").replace(">", "") note = body.get("note", [""])[0].strip() tags = body.get("tags", [""])[0].strip() - if input_type == "url": - if not url: - return handle_add_form("URL is required.") - url = clean_url(url) - if not url.startswith(("http://", "https://")): - return handle_add_form("URL must start with http:// or https://") - else: - if not reticulum_dest: - return handle_add_form("Reticulum destination hash is required.") - if len(reticulum_dest) != 32 or not all(c in "0123456789abcdefABCDEF" for c in reticulum_dest): - return handle_add_form("Invalid reticulum destination hash. Must be 32 hex characters.") - url = f"reticulum:{reticulum_dest}" + if not raw: + return handle_add_form("URL or RNS hash is required.") + + is_rns = ( + len(raw) == 32 + and all(c in "0123456789abcdefABCDEF" for c in raw) + ) + if raw.startswith("rns:") or raw.startswith("RNS:"): + raw = raw[4:] + is_rns = ( + len(raw) == 32 + and all(c in "0123456789abcdefABCDEF" for c in raw) + ) + + if is_rns: + from .rns import handle_rns_add_hash + errs = handle_rns_add_hash(raw) + if errs: + return handle_add_form(f"Hash saved but indexing failed: {'; '.join(errs)}") + return _redirect("/") + + url = clean_url(raw) + if not url.startswith(("http://", "https://")): + return handle_add_form("Enter a URL (http:// or https://) or a 32-char RNS destination hash.") try: - title = index_url(url, note, reticulum_dest if reticulum_dest else "") + title = index_url(url, note) if tags: db = get_db() try: @@ -75,12 +85,9 @@ def handle_add_submit(body): db.commit() finally: return_db(db) - return handle_add_form(f'Indexed: {esc(url)}') - except ValueError as e: return handle_add_form(f"Error: {esc(str(e))}") - except Exception as e: error_msg = str(e).lower() if any(x in error_msg for x in ("block", "cloudflare", "403", "ssl", "handshake", "max retries", "timeout", "connection")): @@ -168,10 +175,17 @@ def handle_pages(query=None): if tags: tag_links = " ".join(f'[{esc(t)}]' for t in tags) tags_html = f' {tag_links}' + url = r["url"] + if url.startswith("rns:"): + display_url = url + link_url = f"/rns/{esc(url[4:])}/" + else: + display_url = url + link_url = url items += ( f'
  • {note_html}{tags_html} ' - f'({esc(r["url"])}) ' + f'({esc(display_url)}) ' f'edit ' f'remove
  • ' ) diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py new file mode 100644 index 0000000..8941f99 --- /dev/null +++ b/src/tinyweb/handlers/rns.py @@ -0,0 +1,163 @@ +import json +import traceback +from tinyweb.db import get_db, return_db +from tinyweb.rns_client import fetch_remote_page +from tinyweb.templates import esc + + +def _get_mesh_sites(): + db = get_db() + try: + return db.execute("SELECT hash, name, added_at FROM mesh_sites ORDER BY added_at DESC").fetchall() + finally: + return_db(db) + + +def handle_rns_add_hash(dest_hash, name=""): + db = get_db() + errors = [] + try: + db.execute( + "INSERT OR REPLACE INTO mesh_sites (hash, name) VALUES (?, ?)", + (dest_hash, name or ""), + ) + + try: + resp = fetch_remote_page(dest_hash, "/") + if resp.get("status") == 200: + body = resp.get("body", "") + title = name or dest_hash[:16] + import re + m = re.search(r"]*>(.*?)", body, re.IGNORECASE | re.DOTALL) + if m: + title = m.group(1).strip() + desc = "" + m = re.search(r']+>", " ", body) + text = re.sub(r"\s+", " ", text).strip() + desc = text[:200].strip() + url = f"rns:{dest_hash}" + import datetime + now = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + db.execute( + "INSERT OR REPLACE INTO pages (url, title, body, last_modified, summary) VALUES (?, ?, ?, ?, ?)", + (url, title, body, now, desc), + ) + else: + errors.append(f"Remote returned status {resp.get('status')}") + except Exception as e: + errors.append(str(e)) + traceback.print_exc() + + db.commit() + finally: + return_db(db) + + return errors if errors else None + + +def handle_rns_delete_hash(body): + dest_hash = body.get("hash", [""])[0].strip() if isinstance(body, dict) else body + db = get_db() + try: + db.execute("DELETE FROM mesh_sites WHERE hash = ?", (dest_hash,)) + db.commit() + finally: + return_db(db) + from tinyweb.handlers.pages import _redirect + return _redirect("/") + + +def _rewrite_links(html, dest_hash): + out = [] + i = 0 + while i < len(html): + href_start = html.find('href="', i) + src_start = html.find('src="', i) + action_start = html.find('action="', i) + + candidates = [] + if href_start >= 0: + candidates.append((href_start, "href", 'href="')) + if src_start >= 0: + candidates.append((src_start, "src", 'src="')) + if action_start >= 0: + candidates.append((action_start, "action", 'action="')) + + if not candidates: + out.append(html[i:]) + break + + candidates.sort() + pos, attr, prefix = candidates[0] + out.append(html[i:pos + len(prefix)]) + + value_start = pos + len(prefix) + value_end = html.find('"', value_start) + if value_end < 0: + out.append(html[value_start:]) + break + value = html[value_start:value_end] + + if value.startswith("/"): + out.append(f"/rns/{dest_hash}{value}") + else: + out.append(value) + + out.append('"') + i = value_end + 1 + + return "".join(out) + + +def handle_rns_browse(path, dest_hash): + prefix = f"/rns/{dest_hash}" + sub_path = path[len(prefix):] if path.startswith(prefix) else "/" + if not sub_path: + sub_path = "/" + + try: + resp = fetch_remote_page(dest_hash, sub_path) + except ConnectionError as e: + return { + "status": 200, + "content_type": "text/html; charset=utf-8", + "body": f"

    could not connect

    {esc(str(e))}

    ", + "headers": {}, + } + except PermissionError: + return { + "status": 200, + "content_type": "text/html; charset=utf-8", + "body": "

    forbidden

    the remote instance blocked this request.

    ", + "headers": {}, + } + + if resp.get("status") != 200: + return { + "status": 200, + "content_type": "text/html; charset=utf-8", + "body": f"

    error

    remote returned status {resp['status']}

    ", + "headers": {}, + } + + body = resp.get("body", "") + + if resp.get("content_type", "").startswith("application/json"): + try: + data = json.loads(body) + body = f"
    {esc(json.dumps(data, indent=2))}
    " + except (json.JSONDecodeError, TypeError): + body = f"
    {esc(body[:2000])}
    " + + body = _rewrite_links(body, dest_hash) + + return { + "status": 200, + "content_type": "text/html; charset=utf-8", + "body": body, + "headers": {}, + } diff --git a/src/tinyweb/handlers/search.py b/src/tinyweb/handlers/search.py index 951b4d4..10b87fd 100644 --- a/src/tinyweb/handlers/search.py +++ b/src/tinyweb/handlers/search.py @@ -83,10 +83,17 @@ def handle_search(query): tag_links = " ".join(f'[{esc(t)}]' for t in tags) tags_html = f'
    {tag_links}
    ' snip_html = f'
    {esc(r["summary"])}' if r["summary"] else "" + url = r["url"] + if url.startswith("rns:"): + display_url = url + link_url = f"/rns/{esc(url[4:])}/" + else: + display_url = url + link_url = url result_html += ( f'
    ' - f'{esc(r["title"])}
    ' - f'{esc(r["url"])}' + f'{esc(r["title"])}
    ' + f'{esc(display_url)}' f'{snip_html}' f'{note_html}{tags_html}' f'
    ' @@ -162,6 +169,7 @@ def handle_search(query): sub_count = "" if q and remote_rows: sub_count = f" + {len(remote_rows)} from subscriptions" + welcome_html = "" if count == 0 and not q: welcome_html = ( diff --git a/src/tinyweb/rns_client.py b/src/tinyweb/rns_client.py index 98df406..a59a654 100644 --- a/src/tinyweb/rns_client.py +++ b/src/tinyweb/rns_client.py @@ -12,21 +12,32 @@ _TIMEOUT_TIERS = [ ] -def fetch_remote_sites(dest_hash_hex, since=""): - """ - Connect to a remote TinyWeb instance over Reticulum and fetch its - shared sites. Returns the response dict from /api/sites, or raises - an exception on failure. Pass `since` as ISO timestamp for delta sync. +# Request path for "/tinyweb" destination +_RNS_REQUEST_PATH = "/tinyweb" - Uses progressive timeouts: tries fast first, then retries with longer - timeouts for slow links (LoRa, multi-hop). + +def fetch_remote_sites(dest_hash_hex, since=""): + resp = _rns_request(dest_hash_hex, "/api/sites", {"since": [since]} if since else {}) + return json.loads(resp.get("body", "{}")) + + +def fetch_remote_page(dest_hash_hex, path, query=None): + return _rns_request(dest_hash_hex, path, query or {}) + + +def _rns_request(dest_hash_hex, path, query=None): + """Generic RNS request to a remote TinyWeb instance. + + Connects over RNS, requests the given path, returns the response dict + (status, content_type, body, headers). Raises on failure. + Uses progressive timeouts: fast first, then slow for LoRa/multi-hop. """ last_error = None for tier in _TIMEOUT_TIERS: try: - return _fetch(dest_hash_hex, since, tier) + return _fetch(dest_hash_hex, path, query or {}, tier) except PermissionError: - raise # Don't retry permission errors + raise except Exception as e: last_error = e continue @@ -35,12 +46,11 @@ def fetch_remote_sites(dest_hash_hex, since=""): ) -def _fetch(dest_hash_hex, since, timeouts): - """Single fetch attempt with the given timeout profile.""" +def _fetch(dest_hash_hex, path, query, timeouts): + """Single RNS fetch attempt with the given timeout profile.""" dest_hash = bytes.fromhex(dest_hash_hex) poll = timeouts["poll"] - # Resolve path if needed if not RNS.Transport.has_path(dest_hash): RNS.Transport.request_path(dest_hash) elapsed = 0 @@ -64,7 +74,6 @@ def _fetch(dest_hash_hex, since, timeouts): *ASPECTS, ) - # Establish link link = RNS.Link(destination) elapsed = 0 while link.status == RNS.Link.PENDING and elapsed < timeouts["link"]: @@ -77,17 +86,15 @@ def _fetch(dest_hash_hex, since, timeouts): ) try: - query = {"since": [since]} if since else {} request_data = { "method": "GET", - "path": "/api/sites", + "path": path, "query": query, "body": {}, "gateway_host": "", } - req_timeout = timeouts["request"] - receipt = link.request("/tinyweb", data=request_data, timeout=req_timeout) + receipt = link.request(_RNS_REQUEST_PATH, data=request_data, timeout=req_timeout) elapsed = 0 done = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED) @@ -98,10 +105,10 @@ def _fetch(dest_hash_hex, since, timeouts): if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED): resp = receipt.get_response() if resp["status"] == 403: - raise PermissionError("That instance has sharing disabled.") + raise PermissionError("Forbidden") if resp["status"] != 200: raise ConnectionError(f"Remote returned status {resp['status']}") - return json.loads(resp["body"]) + return resp else: raise ConnectionError( f"Request failed or timed out ({req_timeout}s timeout)"