rns browser, standalone site server, unified add form

- /rns/<hash>/<path> 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/<hash>/ 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:<hash> URLs displayed in browse/search, linked to /rns/<hash>/
- expand RNS whitelist: allow GET /, /about, /pages, /tags, /share
This commit is contained in:
blankie 2026-06-18 17:15:15 +00:00
parent 4468ba89ad
commit ffdd3c176d
7 changed files with 273 additions and 51 deletions

View file

@ -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",

View file

@ -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:

View file

@ -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/<hash>/<subpath>
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)

View file

@ -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"<h1>add url</h1>"
f"<p>Add a site to your index</p>"
f"<h1>add site</h1>"
f"<p>Add a site to your index — URL or RNS destination hash</p>"
f'<form method="post" action="/add">'
f'{_csrf_field()}'
f'<input name="url" placeholder="https://example.com" size="50" {url_value}><br><br>'
f'<input name="url" placeholder="https://example.com or 32-char RNS hash" size="50" {url_value}><br><br>'
f'<input name="note" placeholder="why are you saving this? (optional)" size="50"><br><br>'
f'<input name="tags" placeholder="tags (comma-separated, e.g. solarpunk, mesh)" size="50"><br>'
f'<small>tag: private to exclude from sharing</small><br><br>'
@ -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'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' 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'<li><label><input type="checkbox" name="ids" value="{r["id"]}"> '
f'{esc(r["title"])}</label>{note_html}{tags_html} '
f'<small>(<a href="{esc(r["url"])}" rel="noreferrer noopener">{esc(r["url"])}</a>)</small> '
f'<small>(<a href="{esc(link_url)}" rel="noreferrer noopener">{esc(display_url)}</a>)</small> '
f'<a href="/edit/{r["id"]}?p={page}">edit</a> '
f'<a href="/delete/{r["id"]}">remove</a></li>'
)

163
src/tinyweb/handlers/rns.py Normal file
View file

@ -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"<title[^>]*>(.*?)</title>", body, re.IGNORECASE | re.DOTALL)
if m:
title = m.group(1).strip()
desc = ""
m = re.search(r'<meta\s+name="description"\s+content="([^"]*)"', body, re.IGNORECASE)
if m:
desc = m.group(1).strip()
if not desc:
text = re.sub(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"<h1>could not connect</h1><p>{esc(str(e))}</p>",
"headers": {},
}
except PermissionError:
return {
"status": 200,
"content_type": "text/html; charset=utf-8",
"body": "<h1>forbidden</h1><p>the remote instance blocked this request.</p>",
"headers": {},
}
if resp.get("status") != 200:
return {
"status": 200,
"content_type": "text/html; charset=utf-8",
"body": f"<h1>error</h1><p>remote returned status {resp['status']}</p>",
"headers": {},
}
body = resp.get("body", "")
if resp.get("content_type", "").startswith("application/json"):
try:
data = json.loads(body)
body = f"<pre>{esc(json.dumps(data, indent=2))}</pre>"
except (json.JSONDecodeError, TypeError):
body = f"<pre>{esc(body[:2000])}</pre>"
body = _rewrite_links(body, dest_hash)
return {
"status": 200,
"content_type": "text/html; charset=utf-8",
"body": body,
"headers": {},
}

View file

@ -83,10 +83,17 @@ def handle_search(query):
tag_links = " ".join(f'<a href="/tags/{esc(t)}" class="tag">[{esc(t)}]</a>' for t in tags)
tags_html = f'<div class="tags">{tag_links}</div>'
snip_html = f'<br>{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'<div class="result">'
f'<a href="{esc(r["url"])}" rel="noreferrer noopener">{esc(r["title"])}</a><br>'
f'<small>{esc(r["url"])}</small>'
f'<a href="{esc(link_url)}" rel="noreferrer noopener">{esc(r["title"])}</a><br>'
f'<small>{esc(display_url)}</small>'
f'{snip_html}'
f'{note_html}{tags_html}'
f'</div>'
@ -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 = (

View file

@ -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)"