diff --git a/README.md b/README.md index c52f06e..73156d0 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ Code generated by LLMs. Built by one person. ## Features - **Personal search index** — Save pages you find valuable, search them with full-text search (SQLite FTS5) +- **RNS live browsing** — Browse any RNS site through your instance via `` tag injection, with LRU caching +- **Unified add form** — Add HTTP URLs or RNS destination hashes through a single form; auto-detection handles both - **Tagging** — Organize saved pages with comma-separated tags - **Bookmarklet** — One-click indexing from any browser tab - **Subscriptions** — Subscribe to friends' TinyWeb instances over Reticulum and search their indexes alongside yours @@ -117,14 +119,14 @@ Data persists in the `tinyweb-data` named volume. On Linux with LAN auto-discove ## Storage Estimates -Average web page content is ~15KB per page: +Pages are stored as cleaned text (HTML tags stripped, boilerplate removed) — typically 5-15 KB per page across both HTTP and RNS sources: | Pages | Database | Embeddings* | Total | |-------|----------|------------|-------| -| 10,000 | 150MB | 80MB | ~250MB | -| 100,000 | 1.5GB | 800MB | ~2.5GB | -| 500,000 | 7.5GB | 4GB | ~12GB | -| 1,000,000 | 15GB | 8GB | ~25GB | +| 10,000 | ~100MB | 80MB | ~180MB | +| 100,000 | ~1GB | 800MB | ~1.8GB | +| 500,000 | ~5GB | 4GB | ~9GB | +| 1,000,000 | ~10GB | 8GB | ~18GB | *Embeddings require semantic search to be enabled. With compression enabled (Settings > Search > AI), embeddings use ~50% less storage. 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..05d885a 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,15 +85,12 @@ 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")): + if any(x in error_msg for x in ("block", "cloudflare", "403", "429", "ssl", "handshake", "max retries", "timeout", "connection")): return _respond( f"

add url (manual entry)

" f"

{esc(url)} blocks automated access. " @@ -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..086acde --- /dev/null +++ b/src/tinyweb/handlers/rns.py @@ -0,0 +1,185 @@ +import json +import time +import threading +import traceback +import datetime +from bs4 import BeautifulSoup +from tinyweb.db import get_db, return_db +from tinyweb.rns_client import fetch_remote_page +from tinyweb.templates import esc + + +class _PageCache: + def __init__(self, maxsize=50, ttl=300): + self._maxsize = maxsize + self._ttl = ttl + self._cache = {} + self._lock = threading.Lock() + + def get(self, key): + with self._lock: + entry = self._cache.get(key) + if entry is None: + return None + if time.time() - entry["time"] > self._ttl: + del self._cache[key] + return None + self._cache.pop(key) + self._cache[key] = entry + return entry["value"] + + def put(self, key, value): + with self._lock: + if key in self._cache: + self._cache.pop(key) + elif len(self._cache) >= self._maxsize: + oldest = next(iter(self._cache)) + del self._cache[oldest] + self._cache[key] = {"value": value, "time": time.time()} + + +_page_cache = _PageCache() + + +def _inject_base_tag(html, dest_hash): + base = f'' + head_start = html.find("= 0: + close = html.find(">", head_start) + if close >= 0: + return html[:close + 1] + base + html[close + 1:] + return f"{base}{html}" + + +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_raw = resp.get("body", "") + soup = BeautifulSoup(body_raw, 'html.parser') + for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "aside"]): + tag.decompose() + cleaned = soup.get_text(separator=" ", strip=True) + + title = name or dest_hash[:16] + if soup.title and soup.title.string: + title = soup.title.string.strip() + + desc = "" + m = soup.find("meta", attrs={"name": "description"}) + if m and m.get("content"): + desc = m["content"].strip() + if not desc: + m = soup.find("meta", attrs={"property": "og:description"}) + if m and m.get("content"): + desc = m["content"].strip() + if not desc: + desc = cleaned[:200].strip() + + url = f"rns:{dest_hash}" + 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, cleaned, 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 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 = "/" + + cache_key = (dest_hash, sub_path) + cached = _page_cache.get(cache_key) + if cached is not None: + return { + "status": 200, + "content_type": "text/html; charset=utf-8", + "body": cached, + "headers": {}, + } + + 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])}
    " + else: + body = _inject_base_tag(body, dest_hash) + + _page_cache.put(cache_key, body) + + 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/handlers/subscriptions.py b/src/tinyweb/handlers/subscriptions.py index 0113d07..a7ba389 100644 --- a/src/tinyweb/handlers/subscriptions.py +++ b/src/tinyweb/handlers/subscriptions.py @@ -1,4 +1,5 @@ import threading +import time from datetime import datetime from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url @@ -10,6 +11,8 @@ from ._helpers import ( ) _sync_threads = {} +_sync_starts = {} +_SYNC_TIMEOUT = 120 MAX_API_SITES = 5000 MAX_BROWSE = 5000 @@ -142,6 +145,13 @@ def handle_subscriptions(msg=""): subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall() finally: return_db(db) + now_t = time.time() + for sub_id, start_t in list(_sync_starts.items()): + if now_t - start_t > _SYNC_TIMEOUT: + set_setting(f"sync_status_{sub_id}", "error:Timed out") + _sync_threads.pop(sub_id, None) + _sync_starts.pop(sub_id, None) + cards = "" for s in subs: sub_id = s["id"] @@ -152,6 +162,9 @@ def handle_subscriptions(msg=""): if is_syncing: status_html = '
    syncing...
    ' + elif sync_status.startswith("done:"): + count = sync_status[5:] + status_html = f'
    synced {esc(count)} site(s)
    ' elif sync_status.startswith("error:"): err_msg = sync_status[6:] status_html = f'
    {esc(err_msg)}
    ' @@ -349,9 +362,10 @@ def handle_subscription_pick(body): def _sync_subscription(sub_id): - set_setting(f"sync_status_{sub_id}", "syncing") - db = get_db() + db = None try: + set_setting(f"sync_status_{sub_id}", "syncing") + db = get_db() sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone() if not sub: set_setting(f"sync_status_{sub_id}", "error:Subscription not found.") @@ -407,13 +421,17 @@ def _sync_subscription(sub_id): except Exception as e: set_setting(f"sync_status_{sub_id}", f"error:{e}") finally: - return_db(db) + if db: + return_db(db) + _sync_threads.pop(sub_id, None) + _sync_starts.pop(sub_id, None) def handle_subscription_sync(sub_id): if sub_id in _sync_threads and _sync_threads[sub_id].is_alive(): return _redirect("/subscriptions") set_setting(f"sync_status_{sub_id}", "syncing") + _sync_starts[sub_id] = time.time() t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True) _sync_threads[sub_id] = t t.start() @@ -454,6 +472,7 @@ def handle_subscription_syncall(): if sub_id in _sync_threads and _sync_threads[sub_id].is_alive(): continue set_setting(f"sync_status_{sub_id}", "syncing") + _sync_starts[sub_id] = time.time() t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True) _sync_threads[sub_id] = t t.start() 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)"