Compare commits

..

2 commits

6 changed files with 231 additions and 113 deletions

104
site_server.py Normal file
View 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()

View file

@ -39,7 +39,7 @@ from .data import (
handle_reindex_form, handle_reindex_submit, _reindex_thread,
)
from .rns import (
handle_rns_list, handle_rns_add, handle_rns_delete, handle_rns_browse,
handle_rns_delete_hash, handle_rns_browse,
)
forum_plugin = None
@ -97,8 +97,6 @@ def _dispatch_inner(data):
if not dest_hash:
return _error(404)
return handle_rns_browse(path, dest_hash)
elif path == "/rns":
return handle_rns_list(query)
elif path == "/reindex":
return handle_reindex_form()
elif path == "/api/sites":
@ -167,10 +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/add":
return handle_rns_add(body)
elif path == "/rns/delete":
return handle_rns_delete(body)
return handle_rns_delete_hash(body)
return _error(404)

View file

@ -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"<h1>add url</h1>"
f"<p>Add a site to your index</p>"
f"<h1>add site</h1>"
f"<p>Add a site to your index — URL or RNS destination hash</p>"
f'<form method="post" action="/add">'
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="tags" placeholder="tags (comma-separated, e.g. solarpunk, mesh)" size="50"><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):
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,12 +85,9 @@ 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")):
@ -168,10 +175,17 @@ def handle_pages(query=None):
if tags:
tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' 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'<li><label><input type="checkbox" name="ids" value="{r["id"]}"> '
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="/delete/{r["id"]}">remove</a></li>'
)

View file

@ -1,10 +1,8 @@
import json
from urllib.parse import urlparse
import traceback
from tinyweb.db import get_db, return_db
from tinyweb.rns_client import fetch_remote_page
from tinyweb.templates import esc
from ._helpers import _respond, _redirect, _error
from .customize import _set_flash
def _get_mesh_sites():
@ -15,12 +13,68 @@ def _get_mesh_sites():
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 = resp.get("body", "")
title = name or dest_hash[:16]
import re
m = re.search(r"<title[^>]*>(.*?)</title>", body, re.IGNORECASE | re.DOTALL)
if m:
title = m.group(1).strip()
desc = ""
m = re.search(r'<meta\s+name="description"\s+content="([^"]*)"', body, re.IGNORECASE)
if m:
desc = m.group(1).strip()
if not desc:
text = re.sub(r"<[^>]+>", " ", body)
text = re.sub(r"\s+", " ", text).strip()
desc = text[:200].strip()
url = f"rns:{dest_hash}"
import datetime
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, body, 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 _rewrite_links(html, dest_hash):
"""Rewrite all relative links in HTML to route through /rns/<hash>/..."""
out = []
i = 0
while i < len(html):
# Check for href="...
href_start = html.find('href="', i)
src_start = html.find('src="', i)
action_start = html.find('action="', i)
@ -41,7 +95,6 @@ def _rewrite_links(html, dest_hash):
pos, attr, prefix = candidates[0]
out.append(html[i:pos + len(prefix)])
# Find end of attribute value
value_start = pos + len(prefix)
value_end = html.find('"', value_start)
if value_end < 0:
@ -50,10 +103,8 @@ def _rewrite_links(html, dest_hash):
value = html[value_start:value_end]
if value.startswith("/"):
# Relative path — rewrite
out.append(f"/rns/{dest_hash}{value}")
else:
# External URL or empty — leave as-is
out.append(value)
out.append('"')
@ -62,76 +113,7 @@ def _rewrite_links(html, dest_hash):
return "".join(out)
def handle_rns_list(query=None):
sites = _get_mesh_sites()
rows_html = ""
for s in sites:
rows_html += (
f'<tr>'
f'<td><a href="/rns/{esc(s["hash"])}/">{esc(s["name"] or s["hash"][:16])}</a></td>'
f'<td style="font-family:monospace;font-size:0.85rem">{esc(s["hash"])}</td>'
f'<td>{esc(s["added_at"])}</td>'
f'<td>'
f'<form action="/rns/delete" method="POST" style="display:inline">'
f'<input type="hidden" name="hash" value="{esc(s["hash"])}">'
f'<button type="submit">delete</button>'
f'</form>'
f'</td>'
f'</tr>'
)
html = (
f'<h1>mesh sites</h1>'
f'<table><tr><th>site</th><th>hash</th><th>added</th><th></th></tr>{rows_html}</table>'
f'<br>'
f'<form action="/rns/add" method="POST">'
f'<input type="text" name="hash" placeholder="RNS destination hash" style="width:32ch;font-family:monospace">'
f'<input type="text" name="name" placeholder="name (optional)" style="width:20ch">'
f'<button type="submit">add site</button>'
f'</form>'
f'<br><a href="/">back</a>'
)
return _respond(html)
def handle_rns_add(body):
dest_hash = body.get("hash", [""])[0].strip()
name = body.get("name", [""])[0].strip()
if not dest_hash:
return handle_rns_list()
try:
bytes.fromhex(dest_hash)
except ValueError:
_set_flash("Invalid hash — must be hex.")
return _redirect("/rns")
db = get_db()
try:
db.execute(
"INSERT OR REPLACE INTO mesh_sites (hash, name) VALUES (?, ?)",
(dest_hash, name or ""),
)
db.commit()
finally:
return_db(db)
return _redirect("/rns")
def handle_rns_delete(body):
dest_hash = body.get("hash", [""])[0].strip()
if not dest_hash:
return _redirect("/rns")
db = get_db()
try:
db.execute("DELETE FROM mesh_sites WHERE hash = ?", (dest_hash,))
db.commit()
finally:
return_db(db)
return _redirect("/rns")
def handle_rns_browse(path, dest_hash):
# Extract the sub-path after /rns/<hash>
prefix = f"/rns/{dest_hash}"
sub_path = path[len(prefix):] if path.startswith(prefix) else "/"
if not sub_path:
@ -140,16 +122,30 @@ def handle_rns_browse(path, dest_hash):
try:
resp = fetch_remote_page(dest_hash, sub_path)
except ConnectionError as e:
return _respond(f"<h1>could not connect</h1><p>{esc(str(e))}</p>")
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 _respond("<h1>forbidden</h1><p>the remote instance blocked this request.</p>")
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 _respond(f"<h1>error</h1><p>remote returned status {resp['status']}</p>")
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 the remote returned JSON (e.g. /api/sites), wrap for display
if resp.get("content_type", "").startswith("application/json"):
try:
data = json.loads(body)

View file

@ -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)
tags_html = f'<div class="tags">{tag_links}</div>'
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 += (
f'<div class="result">'
f'<a href="{esc(r["url"])}" rel="noreferrer noopener">{esc(r["title"])}</a><br>'
f'<small>{esc(r["url"])}</small>'
f'<a href="{esc(link_url)}" rel="noreferrer noopener">{esc(r["title"])}</a><br>'
f'<small>{esc(display_url)}</small>'
f'{snip_html}'
f'{note_html}{tags_html}'
f'</div>'
@ -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 = (

View file

@ -13,7 +13,7 @@ def _nav_html():
return (
f'<p><b><a href="/">{name}</a></b>'
' | <a href="/">search</a> | <a href="/pages">browse</a>'
' | <a href="/tags">tags</a> | <a href="/subscriptions">subscriptions</a> | <a href="/rns">mesh</a>'
' | <a href="/tags">tags</a> | <a href="/subscriptions">subscriptions</a>'
f'{forum_link}'
' | <a href="/style">customize</a> | <a href="/about">about</a></p>\n'
"<hr>\n"