Compare commits
4 commits
dd2f1f1191
...
96441bfc7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96441bfc7c | ||
|
|
7a7211d672 | ||
|
|
d3ed4fcf3c | ||
|
|
48fcaf1627 |
10 changed files with 429 additions and 60 deletions
12
README.md
12
README.md
|
|
@ -25,6 +25,8 @@ Code generated by LLMs. Built by one person.
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Personal search index** — Save pages you find valuable, search them with full-text search (SQLite FTS5)
|
- **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 `<base>` 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
|
- **Tagging** — Organize saved pages with comma-separated tags
|
||||||
- **Bookmarklet** — One-click indexing from any browser tab
|
- **Bookmarklet** — One-click indexing from any browser tab
|
||||||
- **Subscriptions** — Subscribe to friends' TinyWeb instances over Reticulum and search their indexes alongside yours
|
- **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
|
## 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 |
|
| Pages | Database | Embeddings* | Total |
|
||||||
|-------|----------|------------|-------|
|
|-------|----------|------------|-------|
|
||||||
| 10,000 | 150MB | 80MB | ~250MB |
|
| 10,000 | ~100MB | 80MB | ~180MB |
|
||||||
| 100,000 | 1.5GB | 800MB | ~2.5GB |
|
| 100,000 | ~1GB | 800MB | ~1.8GB |
|
||||||
| 500,000 | 7.5GB | 4GB | ~12GB |
|
| 500,000 | ~5GB | 4GB | ~9GB |
|
||||||
| 1,000,000 | 15GB | 8GB | ~25GB |
|
| 1,000,000 | ~10GB | 8GB | ~18GB |
|
||||||
|
|
||||||
*Embeddings require semantic search to be enabled. With compression enabled (Settings > Search > AI), embeddings use ~50% less storage.
|
*Embeddings require semantic search to be enabled. With compression enabled (Settings > Search > AI), embeddings use ~50% less storage.
|
||||||
|
|
||||||
|
|
|
||||||
104
site_server.py
Normal file
104
site_server.py
Normal file
|
|
@ -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"<html><body><h1>404 Not Found</h1><p>{req_path}</p></body></html>"
|
||||||
|
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"<html><body><h1>404 Not Found</h1></body></html>"
|
||||||
|
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()
|
||||||
|
|
@ -73,11 +73,22 @@ def load_or_create_identity():
|
||||||
return identity
|
return identity
|
||||||
|
|
||||||
|
|
||||||
# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface.
|
# Remote peers on the Reticulum mesh can reach read-only public pages.
|
||||||
# Any other method/path is rejected here — CSRF cannot authenticate mesh callers
|
# Only GET is allowed; POST is blocked because CSRF cannot authenticate
|
||||||
# (the attacker controls both the "cookie" and the "form" side of the check), so
|
# mesh callers (the attacker controls both the "cookie" and the "form" side).
|
||||||
# gating by whitelist is the only safe option.
|
_RNS_ALLOWED_GET = {
|
||||||
_RNS_ALLOWED = {("GET", "/api/sites")}
|
"/", "/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):
|
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": ""}
|
data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""}
|
||||||
method = data.get("method", "GET")
|
method = data.get("method", "GET")
|
||||||
req_path = data.get("path", "/")
|
req_path = data.get("path", "/")
|
||||||
if (method, req_path) not in _RNS_ALLOWED:
|
if not _rns_is_allowed(method, req_path):
|
||||||
return {
|
return {
|
||||||
"status": 403,
|
"status": 403,
|
||||||
"content_type": "text/plain; charset=utf-8",
|
"content_type": "text/plain; charset=utf-8",
|
||||||
|
|
|
||||||
|
|
@ -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_delete_hash, handle_rns_browse,
|
||||||
|
)
|
||||||
|
|
||||||
forum_plugin = None
|
forum_plugin = None
|
||||||
|
|
||||||
|
|
@ -87,6 +90,13 @@ 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 == "/reindex":
|
elif path == "/reindex":
|
||||||
return handle_reindex_form()
|
return handle_reindex_form()
|
||||||
elif path == "/api/sites":
|
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)
|
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/delete":
|
||||||
|
return handle_rns_delete_hash(body)
|
||||||
|
|
||||||
return _error(404)
|
return _error(404)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
url_value = f'value="{esc(prefill_url)}" ' if prefill_url else ""
|
||||||
return _respond(
|
return _respond(
|
||||||
f"<h1>add url</h1>"
|
f"<h1>add site</h1>"
|
||||||
f"<p>Add a site to your index</p>"
|
f"<p>Add a site to your index — URL or RNS destination hash</p>"
|
||||||
f'<form method="post" action="/add">'
|
f'<form method="post" action="/add">'
|
||||||
f'{_csrf_field()}'
|
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="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'<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>'
|
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):
|
def handle_add_submit(body):
|
||||||
input_type = body.get("input_type", ["url"])[0]
|
raw = body.get("url", [""])[0].strip().replace("<", "").replace(">", "")
|
||||||
url = body.get("url", [""])[0].strip()
|
|
||||||
reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "")
|
|
||||||
note = body.get("note", [""])[0].strip()
|
note = body.get("note", [""])[0].strip()
|
||||||
tags = body.get("tags", [""])[0].strip()
|
tags = body.get("tags", [""])[0].strip()
|
||||||
|
|
||||||
if input_type == "url":
|
if not raw:
|
||||||
if not url:
|
return handle_add_form("URL or RNS hash is required.")
|
||||||
return handle_add_form("URL is required.")
|
|
||||||
url = clean_url(url)
|
is_rns = (
|
||||||
if not url.startswith(("http://", "https://")):
|
len(raw) == 32
|
||||||
return handle_add_form("URL must start with http:// or https://")
|
and all(c in "0123456789abcdefABCDEF" for c in raw)
|
||||||
else:
|
)
|
||||||
if not reticulum_dest:
|
if raw.startswith("rns:") or raw.startswith("RNS:"):
|
||||||
return handle_add_form("Reticulum destination hash is required.")
|
raw = raw[4:]
|
||||||
if len(reticulum_dest) != 32 or not all(c in "0123456789abcdefABCDEF" for c in reticulum_dest):
|
is_rns = (
|
||||||
return handle_add_form("Invalid reticulum destination hash. Must be 32 hex characters.")
|
len(raw) == 32
|
||||||
url = f"reticulum:{reticulum_dest}"
|
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:
|
try:
|
||||||
title = index_url(url, note, reticulum_dest if reticulum_dest else "")
|
title = index_url(url, note)
|
||||||
if tags:
|
if tags:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
try:
|
try:
|
||||||
|
|
@ -75,15 +85,12 @@ def handle_add_submit(body):
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
return_db(db)
|
return_db(db)
|
||||||
|
|
||||||
return handle_add_form(f'Indexed: {esc(url)}')
|
return handle_add_form(f'Indexed: {esc(url)}')
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return handle_add_form(f"Error: {esc(str(e))}")
|
return handle_add_form(f"Error: {esc(str(e))}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e).lower()
|
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(
|
return _respond(
|
||||||
f"<h1>add url (manual entry)</h1>"
|
f"<h1>add url (manual entry)</h1>"
|
||||||
f"<p><strong>{esc(url)}</strong> blocks automated access. "
|
f"<p><strong>{esc(url)}</strong> blocks automated access. "
|
||||||
|
|
@ -168,10 +175,17 @@ def handle_pages(query=None):
|
||||||
if tags:
|
if tags:
|
||||||
tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags)
|
tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags)
|
||||||
tags_html = f' {tag_links}'
|
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 += (
|
items += (
|
||||||
f'<li><label><input type="checkbox" name="ids" value="{r["id"]}"> '
|
f'<li><label><input type="checkbox" name="ids" value="{r["id"]}"> '
|
||||||
f'{esc(r["title"])}</label>{note_html}{tags_html} '
|
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="/edit/{r["id"]}?p={page}">edit</a> '
|
||||||
f'<a href="/delete/{r["id"]}">remove</a></li>'
|
f'<a href="/delete/{r["id"]}">remove</a></li>'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
185
src/tinyweb/handlers/rns.py
Normal file
185
src/tinyweb/handlers/rns.py
Normal file
|
|
@ -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'<base href="/rns/{dest_hash}/">'
|
||||||
|
head_start = html.find("<head")
|
||||||
|
if head_start >= 0:
|
||||||
|
close = html.find(">", head_start)
|
||||||
|
if close >= 0:
|
||||||
|
return html[:close + 1] + base + html[close + 1:]
|
||||||
|
return f"<head>{base}</head>{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"<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>"
|
||||||
|
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": {},
|
||||||
|
}
|
||||||
|
|
@ -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)
|
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>'
|
tags_html = f'<div class="tags">{tag_links}</div>'
|
||||||
snip_html = f'<br>{esc(r["summary"])}' if r["summary"] else ""
|
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 += (
|
result_html += (
|
||||||
f'<div class="result">'
|
f'<div class="result">'
|
||||||
f'<a href="{esc(r["url"])}" rel="noreferrer noopener">{esc(r["title"])}</a><br>'
|
f'<a href="{esc(link_url)}" rel="noreferrer noopener">{esc(r["title"])}</a><br>'
|
||||||
f'<small>{esc(r["url"])}</small>'
|
f'<small>{esc(display_url)}</small>'
|
||||||
f'{snip_html}'
|
f'{snip_html}'
|
||||||
f'{note_html}{tags_html}'
|
f'{note_html}{tags_html}'
|
||||||
f'</div>'
|
f'</div>'
|
||||||
|
|
@ -162,6 +169,7 @@ def handle_search(query):
|
||||||
sub_count = ""
|
sub_count = ""
|
||||||
if q and remote_rows:
|
if q and remote_rows:
|
||||||
sub_count = f" + {len(remote_rows)} from subscriptions"
|
sub_count = f" + {len(remote_rows)} from subscriptions"
|
||||||
|
|
||||||
welcome_html = ""
|
welcome_html = ""
|
||||||
if count == 0 and not q:
|
if count == 0 and not q:
|
||||||
welcome_html = (
|
welcome_html = (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
|
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_threads = {}
|
||||||
|
_sync_starts = {}
|
||||||
|
_SYNC_TIMEOUT = 120
|
||||||
|
|
||||||
MAX_API_SITES = 5000
|
MAX_API_SITES = 5000
|
||||||
MAX_BROWSE = 5000
|
MAX_BROWSE = 5000
|
||||||
|
|
@ -142,6 +145,13 @@ def handle_subscriptions(msg=""):
|
||||||
subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
|
subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
|
||||||
finally:
|
finally:
|
||||||
return_db(db)
|
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 = ""
|
cards = ""
|
||||||
for s in subs:
|
for s in subs:
|
||||||
sub_id = s["id"]
|
sub_id = s["id"]
|
||||||
|
|
@ -152,6 +162,9 @@ def handle_subscriptions(msg=""):
|
||||||
|
|
||||||
if is_syncing:
|
if is_syncing:
|
||||||
status_html = '<div style="margin-top:0.4rem;font-size:0.85rem;color:#2070c0">syncing...</div>'
|
status_html = '<div style="margin-top:0.4rem;font-size:0.85rem;color:#2070c0">syncing...</div>'
|
||||||
|
elif sync_status.startswith("done:"):
|
||||||
|
count = sync_status[5:]
|
||||||
|
status_html = f'<div style="margin-top:0.4rem;font-size:0.85rem;color:#30a030">synced {esc(count)} site(s)</div>'
|
||||||
elif sync_status.startswith("error:"):
|
elif sync_status.startswith("error:"):
|
||||||
err_msg = sync_status[6:]
|
err_msg = sync_status[6:]
|
||||||
status_html = f'<div style="margin-top:0.4rem;font-size:0.85rem;color:#c03030">{esc(err_msg)}</div>'
|
status_html = f'<div style="margin-top:0.4rem;font-size:0.85rem;color:#c03030">{esc(err_msg)}</div>'
|
||||||
|
|
@ -349,9 +362,10 @@ def handle_subscription_pick(body):
|
||||||
|
|
||||||
|
|
||||||
def _sync_subscription(sub_id):
|
def _sync_subscription(sub_id):
|
||||||
set_setting(f"sync_status_{sub_id}", "syncing")
|
db = None
|
||||||
db = get_db()
|
|
||||||
try:
|
try:
|
||||||
|
set_setting(f"sync_status_{sub_id}", "syncing")
|
||||||
|
db = get_db()
|
||||||
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
|
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
|
||||||
if not sub:
|
if not sub:
|
||||||
set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
|
set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
|
||||||
|
|
@ -407,13 +421,17 @@ def _sync_subscription(sub_id):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
set_setting(f"sync_status_{sub_id}", f"error:{e}")
|
set_setting(f"sync_status_{sub_id}", f"error:{e}")
|
||||||
finally:
|
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):
|
def handle_subscription_sync(sub_id):
|
||||||
if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
|
if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
|
||||||
return _redirect("/subscriptions")
|
return _redirect("/subscriptions")
|
||||||
set_setting(f"sync_status_{sub_id}", "syncing")
|
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)
|
t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
|
||||||
_sync_threads[sub_id] = t
|
_sync_threads[sub_id] = t
|
||||||
t.start()
|
t.start()
|
||||||
|
|
@ -454,6 +472,7 @@ def handle_subscription_syncall():
|
||||||
if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
|
if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
|
||||||
continue
|
continue
|
||||||
set_setting(f"sync_status_{sub_id}", "syncing")
|
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)
|
t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
|
||||||
_sync_threads[sub_id] = t
|
_sync_threads[sub_id] = t
|
||||||
t.start()
|
t.start()
|
||||||
|
|
|
||||||
|
|
@ -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