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"
{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"Add a site to your index
" f'" + f"Browse a remote TinyWeb instance over Reticulum
" + f'" f"{msg}
" f'back' ) @@ -48,60 +59,63 @@ 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(">", "") + name = body.get("name", [""])[0].strip() note = body.get("note", [""])[0].strip() tags = body.get("tags", [""])[0].strip() + if input_type == "rns": + if not reticulum_dest: + return handle_add_form("RNS 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 RNS destination hash. Must be 32 hex characters.") + from .rns import handle_rns_add_hash + handle_rns_add_hash(reticulum_dest, name) + return _redirect("/") + 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://") + + try: + title = index_url(url, note) + if tags: + db = get_db() + try: + row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone() + if row: + _set_page_tags(row["id"], tags, db) + 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")): + return _respond( + f"{esc(url)} blocks automated access. " + f"You can still save it manually:
" + f'" + f'back' + ) + return handle_add_form(f"Error: could not fetch or index that URL. {esc(str(e)[:100])}") 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, reticulum_dest if reticulum_dest else "") - if tags: - db = get_db() - try: - row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone() - if row: - _set_page_tags(row["id"], tags, db) - 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")): - return _respond( - f"{esc(url)} blocks automated access. " - f"You can still save it manually:
" - f'" - f'back' - ) - return handle_add_form(f"Error: could not fetch or index that URL. {esc(str(e)[:100])}") + return handle_add_form("Invalid input type.") def handle_add_manual_submit(body): @@ -148,6 +162,32 @@ def handle_add_manual_submit(body): return_db(db) +def handle_page_view(page_id): + db = get_db() + try: + row = db.execute("SELECT id, url, title, body, note FROM pages WHERE id = ?", (page_id,)).fetchone() + if not row: + return _error(404) + tags = _get_page_tags(row["id"], db) + tag_links = " ".join(f'[{esc(t)}]' for t in tags) if tags else "" + note_html = f'{esc(row["note"])}
' if row["note"] else "" + title = esc(row["title"] or "(untitled)") + body_html = row["body"] or "(no content)" + url_link = f'source: {esc(row["url"])}
' + return _respond( + f"{esc(msg)}
' if msg else "" @@ -170,7 +210,7 @@ def handle_pages(query=None): tags_html = f' {tag_links}' items += ( f'| site | hash | added |
|---|
{esc(str(e))}
") + return { + "status": 200, + "content_type": "text/html; charset=utf-8", + "body": f"{esc(str(e))}
", + "headers": {}, + } except PermissionError: - return _respond("the remote instance blocked this request.
") + return { + "status": 200, + "content_type": "text/html; charset=utf-8", + "body": "the remote instance blocked this request.
", + "headers": {}, + } if resp.get("status") != 200: - return _respond(f"remote returned status {resp['status']}
") + return { + "status": 200, + "content_type": "text/html; charset=utf-8", + "body": f"remote returned status {resp['status']}
", + "headers": {}, + } 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) diff --git a/src/tinyweb/handlers/search.py b/src/tinyweb/handlers/search.py index 951b4d4..9d7dbee 100644 --- a/src/tinyweb/handlers/search.py +++ b/src/tinyweb/handlers/search.py @@ -1,6 +1,6 @@ from tinyweb.db import get_db, return_db, get_setting, get_site_name, clean_url from tinyweb.templates import esc -from ._helpers import _sanitize_fts_query, _paginate, _get_page_tags, _respond, _page_nav, PER_PAGE +from ._helpers import _sanitize_fts_query, _paginate, _get_page_tags, _respond, _page_nav, _csrf_field, PER_PAGE def handle_search(query): @@ -162,6 +162,31 @@ def handle_search(query): sub_count = "" if q and remote_rows: sub_count = f" + {len(remote_rows)} from subscriptions" + + mesh_html = "" + if not q: + from .rns import _get_mesh_sites + sites = _get_mesh_sites() + if sites: + rows_html = "" + for s in sites: + rows_html += ( + f'