diff --git a/README.md b/README.md index 73156d0..c52f06e 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,6 @@ 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 @@ -119,14 +117,14 @@ Data persists in the `tinyweb-data` named volume. On Linux with LAN auto-discove ## Storage Estimates -Pages are stored as cleaned text (HTML tags stripped, boilerplate removed) — typically 5-15 KB per page across both HTTP and RNS sources: +Average web page content is ~15KB per page: | Pages | Database | Embeddings* | Total | |-------|----------|------------|-------| -| 10,000 | ~100MB | 80MB | ~180MB | -| 100,000 | ~1GB | 800MB | ~1.8GB | -| 500,000 | ~5GB | 4GB | ~9GB | -| 1,000,000 | ~10GB | 8GB | ~18GB | +| 10,000 | 150MB | 80MB | ~250MB | +| 100,000 | 1.5GB | 800MB | ~2.5GB | +| 500,000 | 7.5GB | 4GB | ~12GB | +| 1,000,000 | 15GB | 8GB | ~25GB | *Embeddings require semantic search to be enabled. With compression enabled (Settings > Search > AI), embeddings use ~50% less storage. diff --git a/site_server.py b/site_server.py deleted file mode 100644 index a1064eb..0000000 --- a/site_server.py +++ /dev/null @@ -1,104 +0,0 @@ -import os -import sys -import time -import mimetypes - -SITE_DIR = os.path.expanduser("~/apps/tinyweb-site") -DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb") -IDENTITY_FILE = "tinyweb-site_identity" - -APP_NAME = "tinyweb" -ASPECTS = ["server"] -RNS_REQUEST_PATH = "/tinyweb" - -import RNS - - -def load_or_create_identity(): - identity_path = os.path.join(DATA_DIR, IDENTITY_FILE) - if os.path.isfile(identity_path): - return RNS.Identity.from_file(identity_path) - identity = RNS.Identity() - os.makedirs(DATA_DIR, exist_ok=True) - identity.to_file(identity_path) - os.chmod(identity_path, 0o600) - return identity - - -def main(): - configdir = os.environ.get("RNS_CONFIG_DIR") - reticulum = RNS.Reticulum(configdir=configdir) - - identity = load_or_create_identity() - - destination = RNS.Destination( - identity, - RNS.Destination.IN, - RNS.Destination.SINGLE, - APP_NAME, - *ASPECTS, - ) - - destination.register_request_handler( - RNS_REQUEST_PATH, - response_generator=request_handler, - allow=RNS.Destination.ALLOW_ALL, - ) - - destination.announce() - dest_hash = destination.hash.hex() - print(f"tinyweb-site server running!") - print(f"Destination hash: <{dest_hash}>") - print(f"Add this hash to a TinyWeb instance as a mesh site to browse.") - - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - print("\nShutting down...") - destination.unregister_request_handler() - - -def request_handler(path, data, request_id, link_id, remote_identity, requested_at): - if data is None: - data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""} - req_path = data.get("path", "/") - - if req_path in ("/", "/index.html") or not req_path.strip("/"): - fs_path = os.path.join(SITE_DIR, "index.html") - else: - fs_path = os.path.join(SITE_DIR, req_path.lstrip("/")) - - real_path = os.path.realpath(fs_path) - site_real = os.path.realpath(SITE_DIR) - - if not real_path.startswith(site_real + os.sep) and real_path != site_real: - body = f"

404 Not Found

{req_path}

" - return {"status": 404, "content_type": "text/html; charset=utf-8", "body": body, "headers": {}} - - if not os.path.isfile(real_path): - real_path = os.path.join(SITE_DIR, "index.html") - - if not os.path.isfile(real_path): - body = f"

404 Not Found

" - return {"status": 404, "content_type": "text/html; charset=utf-8", "body": body, "headers": {}} - - with open(real_path, "rb") as f: - content = f.read() - - content_type, _ = mimetypes.guess_type(real_path) - if not content_type: - content_type = "text/html; charset=utf-8" - elif content_type.startswith("text/"): - content_type += "; charset=utf-8" - - return { - "status": 200, - "content_type": content_type, - "body": content.decode("utf-8", errors="replace"), - "headers": {}, - } - - -if __name__ == "__main__": - main() diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py index 10b756f..b63ed3c 100644 --- a/src/tinyweb/app.py +++ b/src/tinyweb/app.py @@ -73,22 +73,11 @@ def load_or_create_identity(): return identity -# 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) +# 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")} def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at): @@ -96,7 +85,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 not _rns_is_allowed(method, req_path): + if (method, req_path) not in _RNS_ALLOWED: return { "status": 403, "content_type": "text/plain; charset=utf-8", diff --git a/src/tinyweb/db.py b/src/tinyweb/db.py index c8138dc..0e5cea2 100644 --- a/src/tinyweb/db.py +++ b/src/tinyweb/db.py @@ -241,13 +241,6 @@ 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 14a37fd..524ae8d 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, fetch_remote_page +from tinyweb.rns_client import fetch_remote_sites from ._helpers import ( _request_local, _get_csrf_token, _csrf_field, _check_csrf, @@ -38,9 +38,6 @@ 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 @@ -90,13 +87,6 @@ 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": @@ -165,8 +155,6 @@ 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 05d885a..4eb2a12 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 site

" - f"

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

" + f"

add url

" + f"

Add a site to your index

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

' + f'

' f'

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

' @@ -45,37 +45,27 @@ def handle_add_form(msg="", action_type="index", prefill_url=""): def handle_add_submit(body): - raw = body.get("url", [""])[0].strip().replace("<", "").replace(">", "") + input_type = body.get("input_type", ["url"])[0] + url = body.get("url", [""])[0].strip() + reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "") note = body.get("note", [""])[0].strip() tags = body.get("tags", [""])[0].strip() - 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.") + 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}" try: - title = index_url(url, note) + title = index_url(url, note, reticulum_dest if reticulum_dest else "") if tags: db = get_db() try: @@ -85,12 +75,15 @@ 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", "429", "ssl", "handshake", "max retries", "timeout", "connection")): + if any(x in error_msg for x in ("block", "cloudflare", "403", "ssl", "handshake", "max retries", "timeout", "connection")): return _respond( f"

add url (manual entry)

" f"

{esc(url)} blocks automated access. " @@ -175,17 +168,10 @@ 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(display_url)}) ' + f'({esc(r["url"])}) ' f'edit ' f'remove
  • ' ) diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py deleted file mode 100644 index 086acde..0000000 --- a/src/tinyweb/handlers/rns.py +++ /dev/null @@ -1,185 +0,0 @@ -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 10b87fd..951b4d4 100644 --- a/src/tinyweb/handlers/search.py +++ b/src/tinyweb/handlers/search.py @@ -83,17 +83,10 @@ 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(display_url)}' + f'{esc(r["title"])}
    ' + f'{esc(r["url"])}' f'{snip_html}' f'{note_html}{tags_html}' f'
    ' @@ -169,7 +162,6 @@ 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 a7ba389..0113d07 100644 --- a/src/tinyweb/handlers/subscriptions.py +++ b/src/tinyweb/handlers/subscriptions.py @@ -1,5 +1,4 @@ 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 @@ -11,8 +10,6 @@ from ._helpers import ( ) _sync_threads = {} -_sync_starts = {} -_SYNC_TIMEOUT = 120 MAX_API_SITES = 5000 MAX_BROWSE = 5000 @@ -145,13 +142,6 @@ 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"] @@ -162,9 +152,6 @@ 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)}
    ' @@ -362,10 +349,9 @@ def handle_subscription_pick(body): def _sync_subscription(sub_id): - db = None + set_setting(f"sync_status_{sub_id}", "syncing") + db = get_db() 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.") @@ -421,17 +407,13 @@ def _sync_subscription(sub_id): except Exception as e: set_setting(f"sync_status_{sub_id}", f"error:{e}") finally: - if db: - return_db(db) - _sync_threads.pop(sub_id, None) - _sync_starts.pop(sub_id, None) + return_db(db) 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() @@ -472,7 +454,6 @@ 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 a59a654..98df406 100644 --- a/src/tinyweb/rns_client.py +++ b/src/tinyweb/rns_client.py @@ -12,32 +12,21 @@ _TIMEOUT_TIERS = [ ] -# Request path for "/tinyweb" destination -_RNS_REQUEST_PATH = "/tinyweb" - - 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", "{}")) + """ + 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. - -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. + Uses progressive timeouts: tries fast first, then retries with longer + timeouts for slow links (LoRa, multi-hop). """ last_error = None for tier in _TIMEOUT_TIERS: try: - return _fetch(dest_hash_hex, path, query or {}, tier) + return _fetch(dest_hash_hex, since, tier) except PermissionError: - raise + raise # Don't retry permission errors except Exception as e: last_error = e continue @@ -46,11 +35,12 @@ def _rns_request(dest_hash_hex, path, query=None): ) -def _fetch(dest_hash_hex, path, query, timeouts): - """Single RNS fetch attempt with the given timeout profile.""" +def _fetch(dest_hash_hex, since, timeouts): + """Single 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 @@ -74,6 +64,7 @@ def _fetch(dest_hash_hex, path, query, timeouts): *ASPECTS, ) + # Establish link link = RNS.Link(destination) elapsed = 0 while link.status == RNS.Link.PENDING and elapsed < timeouts["link"]: @@ -86,15 +77,17 @@ def _fetch(dest_hash_hex, path, query, timeouts): ) try: + query = {"since": [since]} if since else {} request_data = { "method": "GET", - "path": path, + "path": "/api/sites", "query": query, "body": {}, "gateway_host": "", } + req_timeout = timeouts["request"] - receipt = link.request(_RNS_REQUEST_PATH, data=request_data, timeout=req_timeout) + receipt = link.request("/tinyweb", data=request_data, timeout=req_timeout) elapsed = 0 done = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED) @@ -105,10 +98,10 @@ def _fetch(dest_hash_hex, path, query, timeouts): if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED): resp = receipt.get_response() if resp["status"] == 403: - raise PermissionError("Forbidden") + raise PermissionError("That instance has sharing disabled.") if resp["status"] != 200: raise ConnectionError(f"Remote returned status {resp['status']}") - return resp + return json.loads(resp["body"]) else: raise ConnectionError( f"Request failed or timed out ({req_timeout}s timeout)"