diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py index 4bb059c..10b756f 100644 --- a/src/tinyweb/app.py +++ b/src/tinyweb/app.py @@ -80,7 +80,7 @@ _RNS_ALLOWED_GET = { "/", "/about", "/api/sites", "/share/preview", } -_RNS_ALLOWED_PREFIXES = ("/pages", "/tags", "/api/sites") +_RNS_ALLOWED_PREFIXES = ("/pages", "/tags", "/api/sites", "/rns") def _rns_is_allowed(method, path): 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..cd52dd6 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_list, handle_rns_add, handle_rns_delete, handle_rns_browse, +) forum_plugin = None @@ -87,6 +90,15 @@ 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 == "/rns": + return handle_rns_list(query) elif path == "/reindex": return handle_reindex_form() elif path == "/api/sites": @@ -155,6 +167,10 @@ 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/add": + return handle_rns_add(body) + elif path == "/rns/delete": + return handle_rns_delete(body) return _error(404) diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py new file mode 100644 index 0000000..af699d4 --- /dev/null +++ b/src/tinyweb/handlers/rns.py @@ -0,0 +1,167 @@ +import json +from urllib.parse import urlparse +from tinyweb.db import get_db, return_db +from tinyweb.rns_client import fetch_remote_page +from tinyweb.templates import esc +from ._helpers import _respond, _redirect, _error +from .customize import _set_flash + + +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 _rewrite_links(html, dest_hash): + """Rewrite all relative links in HTML to route through /rns//...""" + out = [] + i = 0 + while i < len(html): + # Check for href="... + 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)]) + + # Find end of attribute value + 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("/"): + # Relative path — rewrite + out.append(f"/rns/{dest_hash}{value}") + else: + # External URL or empty — leave as-is + out.append(value) + + out.append('"') + i = value_end + 1 + + return "".join(out) + + +def handle_rns_list(query=None): + sites = _get_mesh_sites() + rows_html = "" + for s in sites: + rows_html += ( + f'' + f'{esc(s["name"] or s["hash"][:16])}' + f'{esc(s["hash"])}' + f'{esc(s["added_at"])}' + f'' + f'
' + f'' + f'' + f'
' + f'' + f'' + ) + + html = ( + f'

mesh sites

' + f'{rows_html}
sitehashadded
' + f'
' + f'
' + f'' + f'' + f'' + f'
' + f'
back' + ) + return _respond(html) + + +def handle_rns_add(body): + dest_hash = body.get("hash", [""])[0].strip() + name = body.get("name", [""])[0].strip() + if not dest_hash: + return handle_rns_list() + try: + bytes.fromhex(dest_hash) + except ValueError: + _set_flash("Invalid hash — must be hex.") + return _redirect("/rns") + + db = get_db() + try: + db.execute( + "INSERT OR REPLACE INTO mesh_sites (hash, name) VALUES (?, ?)", + (dest_hash, name or ""), + ) + db.commit() + finally: + return_db(db) + return _redirect("/rns") + + +def handle_rns_delete(body): + dest_hash = body.get("hash", [""])[0].strip() + if not dest_hash: + return _redirect("/rns") + db = get_db() + try: + db.execute("DELETE FROM mesh_sites WHERE hash = ?", (dest_hash,)) + db.commit() + finally: + return_db(db) + return _redirect("/rns") + + +def handle_rns_browse(path, dest_hash): + # Extract the sub-path after /rns/ + 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 _respond(f"

could not connect

{esc(str(e))}

") + except PermissionError: + return _respond("

forbidden

the remote instance blocked this request.

") + + if resp.get("status") != 200: + return _respond(f"

error

remote returned status {resp['status']}

") + + body = resp.get("body", "") + + # If the remote returned JSON (e.g. /api/sites), wrap for display + 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/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)"