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/site_server.py b/site_server.py
new file mode 100644
index 0000000..a1064eb
--- /dev/null
+++ b/site_server.py
@@ -0,0 +1,104 @@
+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 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'