rns browser: browse remote tinyweb sites over reticulum from local ui
- new /rns page lists saved mesh sites, add/delete by hash - GET /rns/<hash>/<path> proxies pages over encrypted RNS with link rewriting so navigation stays within /rns/<hash>/... - new fetch_remote_page() in rns_client.py for generic RNS page fetching (refactored from old _fetch) - mesh_sites table for persisting saved hashes - POST /rns/add and /rns/delete for managing sites
This commit is contained in:
parent
1eb0760a55
commit
8a4857490f
5 changed files with 218 additions and 21 deletions
|
|
@ -80,7 +80,7 @@ _RNS_ALLOWED_GET = {
|
||||||
"/", "/about", "/api/sites", "/share/preview",
|
"/", "/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):
|
def _rns_is_allowed(method, path):
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,13 @@ def init_db():
|
||||||
VALUES (new.id, new.title, new.url, new.note);
|
VALUES (new.id, new.title, new.url, new.note);
|
||||||
END;
|
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
|
# Migrate old subscriptions table if needed
|
||||||
cols = [row[1] for row in db.execute("PRAGMA table_info(subscriptions)").fetchall()]
|
cols = [row[1] for row in db.execute("PRAGMA table_info(subscriptions)").fetchall()]
|
||||||
if "url" in cols and "dest_hash" not in cols:
|
if "url" in cols and "dest_hash" not in cols:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from urllib.parse import unquote
|
||||||
from tinyweb.db import get_db, return_db, set_setting
|
from tinyweb.db import get_db, return_db, set_setting
|
||||||
import tinyweb.templates as templates_mod
|
import tinyweb.templates as templates_mod
|
||||||
from tinyweb.templates import esc, wrap_page
|
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 (
|
from ._helpers import (
|
||||||
_request_local, _get_csrf_token, _csrf_field, _check_csrf,
|
_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_export, handle_import_form, handle_import_submit,
|
||||||
handle_reindex_form, handle_reindex_submit, _reindex_thread,
|
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
|
forum_plugin = None
|
||||||
|
|
||||||
|
|
@ -87,6 +90,15 @@ def _dispatch_inner(data):
|
||||||
elif path.startswith("/tags/"):
|
elif path.startswith("/tags/"):
|
||||||
tag_name = unquote(path[len("/tags/"):])
|
tag_name = unquote(path[len("/tags/"):])
|
||||||
return handle_tag_browse(tag_name, query) if tag_name else _error(400)
|
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 == "/rns":
|
||||||
|
return handle_rns_list(query)
|
||||||
elif path == "/reindex":
|
elif path == "/reindex":
|
||||||
return handle_reindex_form()
|
return handle_reindex_form()
|
||||||
elif path == "/api/sites":
|
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)
|
return handle_subscription_delete(sid) if sid is not None else _error(400)
|
||||||
elif path == "/subscriptions/syncall":
|
elif path == "/subscriptions/syncall":
|
||||||
return handle_subscription_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)
|
return _error(404)
|
||||||
|
|
||||||
|
|
|
||||||
167
src/tinyweb/handlers/rns.py
Normal file
167
src/tinyweb/handlers/rns.py
Normal file
|
|
@ -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/<hash>/..."""
|
||||||
|
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'<tr>'
|
||||||
|
f'<td><a href="/rns/{esc(s["hash"])}/">{esc(s["name"] or s["hash"][:16])}</a></td>'
|
||||||
|
f'<td style="font-family:monospace;font-size:0.85rem">{esc(s["hash"])}</td>'
|
||||||
|
f'<td>{esc(s["added_at"])}</td>'
|
||||||
|
f'<td>'
|
||||||
|
f'<form action="/rns/delete" method="POST" style="display:inline">'
|
||||||
|
f'<input type="hidden" name="hash" value="{esc(s["hash"])}">'
|
||||||
|
f'<button type="submit">delete</button>'
|
||||||
|
f'</form>'
|
||||||
|
f'</td>'
|
||||||
|
f'</tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
html = (
|
||||||
|
f'<h1>mesh sites</h1>'
|
||||||
|
f'<table><tr><th>site</th><th>hash</th><th>added</th><th></th></tr>{rows_html}</table>'
|
||||||
|
f'<br>'
|
||||||
|
f'<form action="/rns/add" method="POST">'
|
||||||
|
f'<input type="text" name="hash" placeholder="RNS destination hash" style="width:32ch;font-family:monospace">'
|
||||||
|
f'<input type="text" name="name" placeholder="name (optional)" style="width:20ch">'
|
||||||
|
f'<button type="submit">add site</button>'
|
||||||
|
f'</form>'
|
||||||
|
f'<br><a href="/">back</a>'
|
||||||
|
)
|
||||||
|
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/<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 _respond(f"<h1>could not connect</h1><p>{esc(str(e))}</p>")
|
||||||
|
except PermissionError:
|
||||||
|
return _respond("<h1>forbidden</h1><p>the remote instance blocked this request.</p>")
|
||||||
|
|
||||||
|
if resp.get("status") != 200:
|
||||||
|
return _respond(f"<h1>error</h1><p>remote returned status {resp['status']}</p>")
|
||||||
|
|
||||||
|
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"<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": {},
|
||||||
|
}
|
||||||
|
|
@ -12,21 +12,32 @@ _TIMEOUT_TIERS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def fetch_remote_sites(dest_hash_hex, since=""):
|
# Request path for "/tinyweb" destination
|
||||||
"""
|
_RNS_REQUEST_PATH = "/tinyweb"
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
last_error = None
|
||||||
for tier in _TIMEOUT_TIERS:
|
for tier in _TIMEOUT_TIERS:
|
||||||
try:
|
try:
|
||||||
return _fetch(dest_hash_hex, since, tier)
|
return _fetch(dest_hash_hex, path, query or {}, tier)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
raise # Don't retry permission errors
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = e
|
last_error = e
|
||||||
continue
|
continue
|
||||||
|
|
@ -35,12 +46,11 @@ def fetch_remote_sites(dest_hash_hex, since=""):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fetch(dest_hash_hex, since, timeouts):
|
def _fetch(dest_hash_hex, path, query, timeouts):
|
||||||
"""Single fetch attempt with the given timeout profile."""
|
"""Single RNS fetch attempt with the given timeout profile."""
|
||||||
dest_hash = bytes.fromhex(dest_hash_hex)
|
dest_hash = bytes.fromhex(dest_hash_hex)
|
||||||
poll = timeouts["poll"]
|
poll = timeouts["poll"]
|
||||||
|
|
||||||
# Resolve path if needed
|
|
||||||
if not RNS.Transport.has_path(dest_hash):
|
if not RNS.Transport.has_path(dest_hash):
|
||||||
RNS.Transport.request_path(dest_hash)
|
RNS.Transport.request_path(dest_hash)
|
||||||
elapsed = 0
|
elapsed = 0
|
||||||
|
|
@ -64,7 +74,6 @@ def _fetch(dest_hash_hex, since, timeouts):
|
||||||
*ASPECTS,
|
*ASPECTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Establish link
|
|
||||||
link = RNS.Link(destination)
|
link = RNS.Link(destination)
|
||||||
elapsed = 0
|
elapsed = 0
|
||||||
while link.status == RNS.Link.PENDING and elapsed < timeouts["link"]:
|
while link.status == RNS.Link.PENDING and elapsed < timeouts["link"]:
|
||||||
|
|
@ -77,17 +86,15 @@ def _fetch(dest_hash_hex, since, timeouts):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query = {"since": [since]} if since else {}
|
|
||||||
request_data = {
|
request_data = {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/sites",
|
"path": path,
|
||||||
"query": query,
|
"query": query,
|
||||||
"body": {},
|
"body": {},
|
||||||
"gateway_host": "",
|
"gateway_host": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
req_timeout = timeouts["request"]
|
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
|
elapsed = 0
|
||||||
done = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED)
|
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):
|
if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED):
|
||||||
resp = receipt.get_response()
|
resp = receipt.get_response()
|
||||||
if resp["status"] == 403:
|
if resp["status"] == 403:
|
||||||
raise PermissionError("That instance has sharing disabled.")
|
raise PermissionError("Forbidden")
|
||||||
if resp["status"] != 200:
|
if resp["status"] != 200:
|
||||||
raise ConnectionError(f"Remote returned status {resp['status']}")
|
raise ConnectionError(f"Remote returned status {resp['status']}")
|
||||||
return json.loads(resp["body"])
|
return resp
|
||||||
else:
|
else:
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
f"Request failed or timed out ({req_timeout}s timeout)"
|
f"Request failed or timed out ({req_timeout}s timeout)"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue