Migrate TinyWeb to Reticulum mesh network
Replace HTTP server with Reticulum-native architecture. The server now speaks only Reticulum, with a client-side gateway providing browser access by translating HTTP to/from RNS requests. - Extract db layer (db.py), templates (templates.py), handlers (handlers.py) - app.py is now the RNS server with persistent identity and destination - gateway.py bridges HTTP on localhost:8080 to RNS link requests - Add rns dependency, add .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5bceff3400
commit
f609f867ef
7 changed files with 1027 additions and 511 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
tinyweb_identity
|
||||
index.db
|
||||
555
app.py
555
app.py
|
|
@ -1,524 +1,57 @@
|
|||
import json
|
||||
import sqlite3
|
||||
import html
|
||||
import requests
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import parse_qs, urlparse, urljoin
|
||||
from bs4 import BeautifulSoup
|
||||
import os
|
||||
import time
|
||||
import RNS
|
||||
|
||||
DATABASE = "index.db"
|
||||
from db import init_db
|
||||
from handlers import dispatch_request
|
||||
|
||||
APP_NAME = "tinyweb"
|
||||
ASPECTS = ["server"]
|
||||
IDENTITY_FILE = "tinyweb_identity"
|
||||
|
||||
|
||||
def get_db():
|
||||
db = sqlite3.connect(DATABASE)
|
||||
db.row_factory = sqlite3.Row
|
||||
return db
|
||||
def load_or_create_identity():
|
||||
if os.path.isfile(IDENTITY_FILE):
|
||||
return RNS.Identity.from_file(IDENTITY_FILE)
|
||||
identity = RNS.Identity()
|
||||
identity.to_file(IDENTITY_FILE)
|
||||
return identity
|
||||
|
||||
|
||||
def init_db():
|
||||
db = sqlite3.connect(DATABASE)
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS pages ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" url TEXT UNIQUE NOT NULL,"
|
||||
" title TEXT,"
|
||||
" body TEXT,"
|
||||
" note TEXT DEFAULT ''"
|
||||
")"
|
||||
def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at):
|
||||
if data is None:
|
||||
data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""}
|
||||
return dispatch_request(data)
|
||||
|
||||
|
||||
def main():
|
||||
init_db()
|
||||
reticulum = RNS.Reticulum()
|
||||
identity = load_or_create_identity()
|
||||
|
||||
destination = RNS.Destination(
|
||||
identity,
|
||||
RNS.Destination.IN,
|
||||
RNS.Destination.SINGLE,
|
||||
APP_NAME,
|
||||
*ASPECTS,
|
||||
)
|
||||
db.execute(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts "
|
||||
"USING fts5(title, body, url, note, content=pages, content_rowid=id)"
|
||||
|
||||
destination.register_request_handler(
|
||||
"/tinyweb",
|
||||
response_generator=rns_request_handler,
|
||||
allow=RNS.Destination.ALLOW_ALL,
|
||||
)
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS links ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" page_id INTEGER NOT NULL,"
|
||||
" url TEXT NOT NULL,"
|
||||
" label TEXT,"
|
||||
" FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE"
|
||||
")"
|
||||
)
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS settings ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
")"
|
||||
)
|
||||
db.executescript("""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
|
||||
INSERT INTO pages_fts(rowid, title, body, url, note)
|
||||
VALUES (new.id, new.title, new.body, new.url, new.note);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
|
||||
INSERT INTO pages_fts(pages_fts, rowid, title, body, url, note)
|
||||
VALUES ('delete', old.id, old.title, old.body, old.url, old.note);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN
|
||||
INSERT INTO pages_fts(pages_fts, rowid, title, body, url, note)
|
||||
VALUES ('delete', old.id, old.title, old.body, old.url, old.note);
|
||||
INSERT INTO pages_fts(rowid, title, body, url, note)
|
||||
VALUES (new.id, new.title, new.body, new.url, new.note);
|
||||
END;
|
||||
""")
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
destination.announce()
|
||||
|
||||
SKIP_EXT = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".zip", ".mp3", ".mp4", ".css", ".js", ".ico", ".xml", ".json")
|
||||
print(f"TinyWeb Reticulum server running")
|
||||
print(f"Destination hash: {RNS.prettyhexrep(destination.hash)}")
|
||||
print(f"Share this hash with clients to connect via gateway.py")
|
||||
|
||||
|
||||
def fetch_page(url):
|
||||
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, verify=False)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
|
||||
# extract links before stripping tags
|
||||
domain = urlparse(url).netloc
|
||||
seen = set()
|
||||
links = []
|
||||
for a in soup.find_all("a", href=True):
|
||||
href = urljoin(url, a["href"]).split("#")[0]
|
||||
parsed = urlparse(href)
|
||||
if parsed.netloc != domain:
|
||||
continue
|
||||
if any(href.lower().endswith(ext) for ext in SKIP_EXT):
|
||||
continue
|
||||
if parsed.query or "action=" in href:
|
||||
continue
|
||||
path = parsed.path.lower()
|
||||
if any(s in path for s in ("/special:", "/talk:", "/user:", "/wikipedia:", "/help:", "/portal:", "/file:", "/category:")):
|
||||
continue
|
||||
if href in seen or href == url:
|
||||
continue
|
||||
seen.add(href)
|
||||
label = a.get_text(strip=True) or href
|
||||
links.append((href, label[:200]))
|
||||
|
||||
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
||||
tag.decompose()
|
||||
title = soup.title.string.strip() if soup.title and soup.title.string else url
|
||||
body = soup.get_text(separator=" ", strip=True)
|
||||
return title, body, links
|
||||
|
||||
|
||||
def snippet(text, query, ctx=80):
|
||||
pos = text.lower().find(query.lower())
|
||||
if pos == -1:
|
||||
return text[:200]
|
||||
start = max(0, pos - ctx)
|
||||
end = min(len(text), pos + len(query) + ctx)
|
||||
return ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "")
|
||||
|
||||
|
||||
def esc(s):
|
||||
return html.escape(str(s))
|
||||
|
||||
|
||||
def get_setting(key, default=""):
|
||||
db = get_db()
|
||||
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
db.close()
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def set_setting(key, value):
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def get_site_name():
|
||||
return get_setting("site_name", "tinyweb")
|
||||
|
||||
|
||||
def wrap_page(body_html):
|
||||
css = get_setting("custom_css")
|
||||
style = f"<style>{css}</style>" if css else ""
|
||||
return f"<html><head>{style}</head><body>{body_html}</body></html>"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def respond(self, body, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(wrap_page(body).encode())
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
if path == "/":
|
||||
self.handle_search(params)
|
||||
elif path == "/add":
|
||||
self.handle_add_form()
|
||||
elif path == "/pages":
|
||||
self.handle_pages()
|
||||
elif path.startswith("/delete/"):
|
||||
self.handle_delete(path)
|
||||
elif path.startswith("/edit/"):
|
||||
self.handle_edit_form(path)
|
||||
elif path == "/style":
|
||||
self.handle_style_form()
|
||||
elif path == "/bookmark":
|
||||
self.handle_bookmark(params)
|
||||
elif path == "/export":
|
||||
self.handle_export()
|
||||
elif path == "/import":
|
||||
self.handle_import_form()
|
||||
else:
|
||||
self.respond("<h1>404</h1>", 404)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length).decode()
|
||||
params = parse_qs(body)
|
||||
|
||||
if self.path == "/add":
|
||||
self.handle_add_submit(params)
|
||||
elif self.path.startswith("/edit/"):
|
||||
self.handle_edit_submit(self.path, params)
|
||||
elif self.path == "/style":
|
||||
self.handle_style_submit(params)
|
||||
elif self.path == "/import":
|
||||
self.handle_import_submit(params)
|
||||
else:
|
||||
self.respond("<h1>404</h1>", 404)
|
||||
|
||||
def handle_search(self, params):
|
||||
q = params.get("q", [""])[0].strip()
|
||||
db = get_db()
|
||||
count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
|
||||
name = get_site_name()
|
||||
|
||||
result_html = ""
|
||||
trusted_html = ""
|
||||
if q:
|
||||
rows = db.execute(
|
||||
"SELECT p.id, p.url, p.title, p.body, p.note "
|
||||
"FROM pages_fts f JOIN pages p ON f.rowid = p.id "
|
||||
"WHERE pages_fts MATCH ? ORDER BY rank LIMIT 50",
|
||||
(q,),
|
||||
).fetchall()
|
||||
if rows:
|
||||
for r in rows:
|
||||
note_html = ""
|
||||
if r["note"]:
|
||||
note_html = f'<div class="note"><em>{esc(r["note"])}</em></div>'
|
||||
result_html += (
|
||||
f'<div class="result">'
|
||||
f'<a href="{esc(r["url"])}">{esc(r["title"])}</a><br>'
|
||||
f'<small>{esc(r["url"])}</small><br>'
|
||||
f'{esc(snippet(r["body"], q))}'
|
||||
f'{note_html}'
|
||||
f'</div>'
|
||||
)
|
||||
else:
|
||||
result_html = "<p>No results in your index.</p>"
|
||||
|
||||
# search all linked pages from trusted sites
|
||||
words = q.lower().split()
|
||||
all_links = db.execute(
|
||||
"SELECT l.url, l.label, p.title AS source_title "
|
||||
"FROM links l JOIN pages p ON l.page_id = p.id",
|
||||
).fetchall()
|
||||
indexed_urls = set(r["url"] for r in rows) if rows else set()
|
||||
seen = set()
|
||||
trusted = []
|
||||
for l in all_links:
|
||||
if l["url"] in indexed_urls or l["url"] in seen:
|
||||
continue
|
||||
if any(w in l["label"].lower() for w in words):
|
||||
seen.add(l["url"])
|
||||
trusted.append(l)
|
||||
if len(trusted) >= 20:
|
||||
break
|
||||
|
||||
if trusted:
|
||||
items = ""
|
||||
for l in trusted:
|
||||
items += (
|
||||
f'<li><a href="{esc(l["url"])}">{esc(l["label"])}</a> '
|
||||
f'<small>— from {esc(l["source_title"])}</small></li>'
|
||||
)
|
||||
trusted_html = (
|
||||
f'<details class="trusted">'
|
||||
f'<summary>from your trusted sites ({len(trusted)})</summary>'
|
||||
f'<ul>{items}</ul>'
|
||||
f'</details>'
|
||||
)
|
||||
|
||||
db.close()
|
||||
self.respond(
|
||||
f'<h1><a href="/">{esc(name)}</a></h1>'
|
||||
f'<form method="get" action="/">'
|
||||
f'<input name="q" value="{esc(q)}" placeholder="search your index" size="40">'
|
||||
f' <button type="submit">search</button>'
|
||||
f'</form>'
|
||||
f'<p>{count} page(s) indexed.'
|
||||
f' <a href="/add">+ add url</a>'
|
||||
f' | <a href="/pages">browse</a>'
|
||||
f' | <a href="/style">customize</a></p>'
|
||||
f'<hr>{result_html}{trusted_html}'
|
||||
)
|
||||
|
||||
def handle_add_form(self, msg=""):
|
||||
self.respond(
|
||||
f"<h1>add url</h1>"
|
||||
f'<form method="post" action="/add">'
|
||||
f'<input name="url" placeholder="https://example.com" size="50"><br><br>'
|
||||
f'<input name="note" placeholder="why are you saving this? (optional)" size="50"><br><br>'
|
||||
f'<button type="submit">index</button>'
|
||||
f"</form>"
|
||||
f"<p>{msg}</p>"
|
||||
f'<a href="/">back</a>'
|
||||
)
|
||||
|
||||
def handle_add_submit(self, params):
|
||||
url = params.get("url", [""])[0].strip()
|
||||
note = params.get("note", [""])[0].strip()
|
||||
if not url:
|
||||
return self.handle_add_form("URL is required.")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return self.handle_add_form("URL must start with http:// or https://")
|
||||
try:
|
||||
title, body, links = fetch_page(url)
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"INSERT INTO pages (url, title, body, note) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, note=excluded.note",
|
||||
(url, title, body, note),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
||||
for href, label in links:
|
||||
db.execute(
|
||||
"INSERT INTO links (page_id, url, label) VALUES (?, ?, ?)",
|
||||
(page_id, href, label),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
self.handle_add_form(f'Indexed: <a href="{esc(url)}">{esc(title)}</a>')
|
||||
except Exception as e:
|
||||
self.handle_add_form(f"Error: {esc(str(e))}")
|
||||
|
||||
def handle_pages(self):
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall()
|
||||
db.close()
|
||||
items = ""
|
||||
for r in rows:
|
||||
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
|
||||
items += (
|
||||
f'<li>{esc(r["title"])}{note_html} '
|
||||
f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small> '
|
||||
f'<a href="/edit/{r["id"]}">edit</a> '
|
||||
f'<a href="/delete/{r["id"]}">remove</a></li>'
|
||||
)
|
||||
self.respond(
|
||||
f"<h1>indexed pages ({len(rows)})</h1>"
|
||||
f"<ul>{items}</ul>"
|
||||
f'<p><a href="/export">export</a> | <a href="/import">import</a></p>'
|
||||
f'<a href="/">back</a>'
|
||||
)
|
||||
|
||||
def handle_edit_form(self, path, msg=""):
|
||||
try:
|
||||
page_id = int(path.split("/")[-1])
|
||||
except ValueError:
|
||||
return self.respond("<h1>400</h1>", 400)
|
||||
db = get_db()
|
||||
row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone()
|
||||
db.close()
|
||||
if not row:
|
||||
return self.respond("<h1>404</h1>", 404)
|
||||
self.respond(
|
||||
f"<h1>edit note</h1>"
|
||||
f"<p><b>{esc(row['title'])}</b><br>"
|
||||
f"<small>{esc(row['url'])}</small></p>"
|
||||
f'<form method="post" action="/edit/{row["id"]}">'
|
||||
f'<input name="note" value="{esc(row["note"])}" placeholder="why did you save this?" size="50"><br><br>'
|
||||
f'<button type="submit">save</button>'
|
||||
f"</form>"
|
||||
f"<p>{msg}</p>"
|
||||
f'<a href="/pages">back</a>'
|
||||
)
|
||||
|
||||
def handle_edit_submit(self, path, params):
|
||||
try:
|
||||
page_id = int(path.split("/")[-1])
|
||||
except ValueError:
|
||||
return self.respond("<h1>400</h1>", 400)
|
||||
note = params.get("note", [""])[0].strip()
|
||||
db = get_db()
|
||||
db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/pages")
|
||||
self.end_headers()
|
||||
|
||||
def handle_delete(self, path):
|
||||
try:
|
||||
page_id = int(path.split("/")[-1])
|
||||
except ValueError:
|
||||
return self.respond("<h1>400</h1>", 400)
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
||||
db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/pages")
|
||||
self.end_headers()
|
||||
|
||||
def handle_bookmark(self, params):
|
||||
url = params.get("url", [""])[0].strip()
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"error: invalid url")
|
||||
return
|
||||
try:
|
||||
title, body, links = fetch_page(url)
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"INSERT INTO pages (url, title, body, note) VALUES (?, ?, ?, '') "
|
||||
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body",
|
||||
(url, title, body),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
||||
for href, label in links:
|
||||
db.execute(
|
||||
"INSERT INTO links (page_id, url, label) VALUES (?, ?, ?)",
|
||||
(page_id, href, label),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
msg = f"ok: {title}"
|
||||
except Exception as e:
|
||||
msg = f"error: {e}"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(msg.encode())
|
||||
|
||||
def handle_export(self):
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT url, title, note FROM pages ORDER BY id").fetchall()
|
||||
db.close()
|
||||
data = [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in rows]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Disposition", "attachment; filename=tinyweb-export.json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, indent=2).encode())
|
||||
|
||||
def handle_import_form(self, msg=""):
|
||||
self.respond(
|
||||
f"<h1>import</h1>"
|
||||
f"<p>Paste the contents of a tinyweb export file (JSON).</p>"
|
||||
f'<form method="post" action="/import">'
|
||||
f'<textarea name="data" rows="12" cols="60" placeholder=\'[{{"url": "...", "note": "..."}}]\'></textarea><br><br>'
|
||||
f'<button type="submit">import</button>'
|
||||
f"</form>"
|
||||
f"<p>{msg}</p>"
|
||||
f'<a href="/pages">back</a>'
|
||||
)
|
||||
|
||||
def handle_import_submit(self, params):
|
||||
raw = params.get("data", [""])[0].strip()
|
||||
if not raw:
|
||||
return self.handle_import_form("Paste JSON data.")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return self.handle_import_form("Invalid JSON.")
|
||||
if not isinstance(data, list):
|
||||
return self.handle_import_form("Expected a JSON array.")
|
||||
|
||||
imported = 0
|
||||
errors = 0
|
||||
for entry in data:
|
||||
url = entry.get("url", "").strip()
|
||||
note = entry.get("note", "").strip()
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
title, body, links = fetch_page(url)
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"INSERT INTO pages (url, title, body, note) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, note=excluded.note",
|
||||
(url, title, body, note),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
||||
for href, label in links:
|
||||
db.execute(
|
||||
"INSERT INTO links (page_id, url, label) VALUES (?, ?, ?)",
|
||||
(page_id, href, label),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
imported += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
|
||||
self.handle_import_form(f"Imported {imported} page(s). {errors} error(s).")
|
||||
|
||||
def handle_style_form(self, msg=""):
|
||||
css = get_setting("custom_css")
|
||||
name = get_site_name()
|
||||
self.respond(
|
||||
f"<h1>customize</h1>"
|
||||
f"<h2>name your search engine</h2>"
|
||||
f'<form method="post" action="/style">'
|
||||
f'<input name="site_name" value="{esc(name)}" placeholder="tinyweb" size="30"><br><br>'
|
||||
f"<h2>custom css</h2>"
|
||||
f"<p>Some classes you can target:</p>"
|
||||
f"<pre>"
|
||||
f"body - page background, font\n"
|
||||
f"h1 - page titles\n"
|
||||
f"input, button - search bar\n"
|
||||
f"a - links\n"
|
||||
f".result - each search result\n"
|
||||
f".note - your notes on results\n"
|
||||
f".trusted - trusted sites dropdown\n"
|
||||
f"small - url text\n"
|
||||
f"ul, li - browse page list"
|
||||
f"</pre>"
|
||||
f'<textarea name="css" rows="16" cols="60">{esc(css)}</textarea><br><br>'
|
||||
f'<button type="submit">save</button>'
|
||||
f"</form>"
|
||||
f"<h2>bookmarklet</h2>"
|
||||
f"<p>Drag this link to your bookmarks bar. Click it on any page to index it instantly.</p>"
|
||||
f'<p><a href="javascript:void(fetch(\'http://{esc(self.headers.get("Host", "localhost:5001"))}/bookmark?url=\'+encodeURIComponent(location.href)).then(r=>r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}</a></p>'
|
||||
f"<p>{msg}</p>"
|
||||
f'<a href="/">back</a>'
|
||||
)
|
||||
|
||||
def handle_style_submit(self, params):
|
||||
css = params.get("css", [""])[0]
|
||||
name = params.get("site_name", ["tinyweb"])[0].strip()
|
||||
set_setting("custom_css", css)
|
||||
set_setting("site_name", name or "tinyweb")
|
||||
self.handle_style_form("Saved.")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
print("running on http://0.0.0.0:5001")
|
||||
HTTPServer(("0.0.0.0", 5001), Handler).serve_forever()
|
||||
main()
|
||||
|
|
|
|||
149
db.py
Normal file
149
db.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import sqlite3
|
||||
import requests
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
DATABASE = "index.db"
|
||||
|
||||
SKIP_EXT = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".zip", ".mp3", ".mp4", ".css", ".js", ".ico", ".xml", ".json")
|
||||
|
||||
|
||||
def get_db():
|
||||
db = sqlite3.connect(DATABASE)
|
||||
db.row_factory = sqlite3.Row
|
||||
return db
|
||||
|
||||
|
||||
def init_db():
|
||||
db = sqlite3.connect(DATABASE)
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS pages ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" url TEXT UNIQUE NOT NULL,"
|
||||
" title TEXT,"
|
||||
" body TEXT,"
|
||||
" note TEXT DEFAULT ''"
|
||||
")"
|
||||
)
|
||||
db.execute(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts "
|
||||
"USING fts5(title, body, url, note, content=pages, content_rowid=id)"
|
||||
)
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS links ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" page_id INTEGER NOT NULL,"
|
||||
" url TEXT NOT NULL,"
|
||||
" label TEXT,"
|
||||
" FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE"
|
||||
")"
|
||||
)
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS settings ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
")"
|
||||
)
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS subscriptions ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" url TEXT UNIQUE NOT NULL,"
|
||||
" name TEXT DEFAULT '',"
|
||||
" auto_sync INTEGER DEFAULT 0,"
|
||||
" last_sync TEXT DEFAULT ''"
|
||||
")"
|
||||
)
|
||||
db.executescript("""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
|
||||
INSERT INTO pages_fts(rowid, title, body, url, note)
|
||||
VALUES (new.id, new.title, new.body, new.url, new.note);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
|
||||
INSERT INTO pages_fts(pages_fts, rowid, title, body, url, note)
|
||||
VALUES ('delete', old.id, old.title, old.body, old.url, old.note);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN
|
||||
INSERT INTO pages_fts(pages_fts, rowid, title, body, url, note)
|
||||
VALUES ('delete', old.id, old.title, old.body, old.url, old.note);
|
||||
INSERT INTO pages_fts(rowid, title, body, url, note)
|
||||
VALUES (new.id, new.title, new.body, new.url, new.note);
|
||||
END;
|
||||
""")
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def get_setting(key, default=""):
|
||||
db = get_db()
|
||||
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
db.close()
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def set_setting(key, value):
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def get_site_name():
|
||||
return get_setting("site_name", "tinyweb")
|
||||
|
||||
|
||||
def fetch_page(url):
|
||||
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, verify=False)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
|
||||
# extract links before stripping tags
|
||||
domain = urlparse(url).netloc
|
||||
seen = set()
|
||||
links = []
|
||||
for a in soup.find_all("a", href=True):
|
||||
href = urljoin(url, a["href"]).split("#")[0]
|
||||
parsed = urlparse(href)
|
||||
if parsed.netloc != domain:
|
||||
continue
|
||||
if any(href.lower().endswith(ext) for ext in SKIP_EXT):
|
||||
continue
|
||||
if parsed.query or "action=" in href:
|
||||
continue
|
||||
path = parsed.path.lower()
|
||||
if any(s in path for s in ("/special:", "/talk:", "/user:", "/wikipedia:", "/help:", "/portal:", "/file:", "/category:")):
|
||||
continue
|
||||
if href in seen or href == url:
|
||||
continue
|
||||
seen.add(href)
|
||||
label = a.get_text(strip=True) or href
|
||||
links.append((href, label[:200]))
|
||||
|
||||
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
||||
tag.decompose()
|
||||
title = soup.title.string.strip() if soup.title and soup.title.string else url
|
||||
body = soup.get_text(separator=" ", strip=True)
|
||||
return title, body, links
|
||||
|
||||
|
||||
def index_url(url, note=""):
|
||||
title, body, links = fetch_page(url)
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"INSERT INTO pages (url, title, body, note) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, note=excluded.note",
|
||||
(url, title, body, note),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
||||
for href, label in links:
|
||||
db.execute(
|
||||
"INSERT INTO links (page_id, url, label) VALUES (?, ?, ?)",
|
||||
(page_id, href, label),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return title
|
||||
149
gateway.py
Normal file
149
gateway.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import sys
|
||||
import time
|
||||
import threading
|
||||
import RNS
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
APP_NAME = "tinyweb"
|
||||
ASPECTS = ["server"]
|
||||
GATEWAY_PORT = 8080
|
||||
REQUEST_TIMEOUT = 60
|
||||
|
||||
|
||||
class GatewayState:
|
||||
reticulum = None
|
||||
destination = None
|
||||
link = None
|
||||
link_lock = threading.Lock()
|
||||
|
||||
|
||||
def resolve_destination(dest_hash_hex):
|
||||
dest_hash = bytes.fromhex(dest_hash_hex)
|
||||
|
||||
if not RNS.Transport.has_path(dest_hash):
|
||||
RNS.Transport.request_path(dest_hash)
|
||||
print(f"Requesting path to {RNS.prettyhexrep(dest_hash)}...")
|
||||
elapsed = 0
|
||||
while not RNS.Transport.has_path(dest_hash) and elapsed < 15:
|
||||
time.sleep(0.5)
|
||||
elapsed += 0.5
|
||||
if not RNS.Transport.has_path(dest_hash):
|
||||
raise ConnectionError(f"Could not find path to {RNS.prettyhexrep(dest_hash)}")
|
||||
|
||||
server_identity = RNS.Identity.recall(dest_hash)
|
||||
GatewayState.destination = RNS.Destination(
|
||||
server_identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
APP_NAME,
|
||||
*ASPECTS,
|
||||
)
|
||||
print(f"Resolved destination: {RNS.prettyhexrep(dest_hash)}")
|
||||
|
||||
|
||||
def ensure_link():
|
||||
with GatewayState.link_lock:
|
||||
if GatewayState.link and GatewayState.link.status == RNS.Link.ACTIVE:
|
||||
return GatewayState.link
|
||||
|
||||
print("Establishing link...")
|
||||
link = RNS.Link(GatewayState.destination)
|
||||
elapsed = 0
|
||||
while link.status == RNS.Link.PENDING and elapsed < 15:
|
||||
time.sleep(0.25)
|
||||
elapsed += 0.25
|
||||
|
||||
if link.status != RNS.Link.ACTIVE:
|
||||
raise ConnectionError("Link establishment failed")
|
||||
|
||||
GatewayState.link = link
|
||||
print("Link established")
|
||||
return link
|
||||
|
||||
|
||||
class GatewayHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _forward(self, method):
|
||||
parsed = urlparse(self.path)
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
body = {}
|
||||
if method == "POST":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
raw = self.rfile.read(length).decode()
|
||||
body = parse_qs(raw)
|
||||
|
||||
request_data = {
|
||||
"method": method,
|
||||
"path": parsed.path,
|
||||
"query": query,
|
||||
"body": body,
|
||||
"gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"),
|
||||
}
|
||||
|
||||
try:
|
||||
link = ensure_link()
|
||||
receipt = link.request(
|
||||
"/tinyweb",
|
||||
data=request_data,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
# Wait for the response
|
||||
elapsed = 0
|
||||
done_statuses = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED)
|
||||
while receipt.get_status() not in done_statuses and elapsed < REQUEST_TIMEOUT:
|
||||
time.sleep(0.1)
|
||||
elapsed += 0.1
|
||||
|
||||
if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED):
|
||||
resp = receipt.get_response()
|
||||
self.send_response(resp["status"])
|
||||
self.send_header("Content-Type", resp.get("content_type", "text/html; charset=utf-8"))
|
||||
for k, v in resp.get("headers", {}).items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
resp_body = resp.get("body", "")
|
||||
if resp_body:
|
||||
self.wfile.write(resp_body.encode() if isinstance(resp_body, str) else resp_body)
|
||||
elif receipt.get_status() == RNS.RequestReceipt.FAILED:
|
||||
self.send_error(504, "Request to TinyWeb server failed")
|
||||
else:
|
||||
self.send_error(504, "Request to TinyWeb server timed out")
|
||||
|
||||
except ConnectionError as e:
|
||||
GatewayState.link = None
|
||||
self.send_error(502, f"Gateway error: {e}")
|
||||
except Exception as e:
|
||||
GatewayState.link = None
|
||||
self.send_error(502, f"Gateway error: {e}")
|
||||
|
||||
def do_GET(self):
|
||||
self._forward("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self._forward("POST")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[Gateway] {args[0]}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: python gateway.py <destination_hash>")
|
||||
print(f" The destination hash is printed by app.py on startup.")
|
||||
sys.exit(1)
|
||||
|
||||
dest_hash = sys.argv[1].replace("<", "").replace(">", "")
|
||||
|
||||
GatewayState.reticulum = RNS.Reticulum()
|
||||
resolve_destination(dest_hash)
|
||||
|
||||
print(f"Gateway listening on http://localhost:{GATEWAY_PORT}")
|
||||
print(f"Open http://localhost:{GATEWAY_PORT} in your browser")
|
||||
HTTPServer(("127.0.0.1", GATEWAY_PORT), GatewayHandler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
660
handlers.py
Normal file
660
handlers.py
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
import requests
|
||||
|
||||
from db import get_db, get_setting, set_setting, get_site_name, index_url
|
||||
from templates import esc, snippet, wrap_page
|
||||
|
||||
|
||||
def _respond(body_html, status=200):
|
||||
return {
|
||||
"status": status,
|
||||
"content_type": "text/html; charset=utf-8",
|
||||
"body": wrap_page(body_html),
|
||||
"headers": {},
|
||||
}
|
||||
|
||||
|
||||
def _redirect(location):
|
||||
return {
|
||||
"status": 302,
|
||||
"content_type": "text/html; charset=utf-8",
|
||||
"body": "",
|
||||
"headers": {"Location": location},
|
||||
}
|
||||
|
||||
|
||||
def _json_response(data, status=200, headers=None):
|
||||
return {
|
||||
"status": status,
|
||||
"content_type": "application/json",
|
||||
"body": json.dumps(data, indent=2),
|
||||
"headers": headers or {},
|
||||
}
|
||||
|
||||
|
||||
def _text_response(text, status=200, headers=None):
|
||||
return {
|
||||
"status": status,
|
||||
"content_type": "text/plain",
|
||||
"body": text,
|
||||
"headers": headers or {},
|
||||
}
|
||||
|
||||
|
||||
def _error(status):
|
||||
return _respond(f"<h1>{status}</h1>", status)
|
||||
|
||||
|
||||
# --- Route handlers ---
|
||||
|
||||
|
||||
def handle_search(query):
|
||||
q = query.get("q", [""])[0].strip()
|
||||
db = get_db()
|
||||
count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
|
||||
name = get_site_name()
|
||||
|
||||
result_html = ""
|
||||
trusted_html = ""
|
||||
if q:
|
||||
rows = db.execute(
|
||||
"SELECT p.id, p.url, p.title, p.body, p.note "
|
||||
"FROM pages_fts f JOIN pages p ON f.rowid = p.id "
|
||||
"WHERE pages_fts MATCH ? ORDER BY rank LIMIT 50",
|
||||
(q,),
|
||||
).fetchall()
|
||||
if rows:
|
||||
for r in rows:
|
||||
note_html = ""
|
||||
if r["note"]:
|
||||
note_html = f'<div class="note"><em>{esc(r["note"])}</em></div>'
|
||||
result_html += (
|
||||
f'<div class="result">'
|
||||
f'<a href="{esc(r["url"])}">{esc(r["title"])}</a><br>'
|
||||
f'<small>{esc(r["url"])}</small><br>'
|
||||
f'{esc(snippet(r["body"], q))}'
|
||||
f'{note_html}'
|
||||
f'</div>'
|
||||
)
|
||||
else:
|
||||
result_html = "<p>No results in your index.</p>"
|
||||
|
||||
# search all linked pages from trusted sites
|
||||
words = q.lower().split()
|
||||
all_links = db.execute(
|
||||
"SELECT l.url, l.label, p.title AS source_title "
|
||||
"FROM links l JOIN pages p ON l.page_id = p.id",
|
||||
).fetchall()
|
||||
indexed_urls = set(r["url"] for r in rows) if rows else set()
|
||||
seen = set()
|
||||
trusted = []
|
||||
for l in all_links:
|
||||
if l["url"] in indexed_urls or l["url"] in seen:
|
||||
continue
|
||||
if any(w in l["label"].lower() for w in words):
|
||||
seen.add(l["url"])
|
||||
trusted.append(l)
|
||||
if len(trusted) >= 20:
|
||||
break
|
||||
|
||||
if trusted:
|
||||
items = ""
|
||||
for l in trusted:
|
||||
items += (
|
||||
f'<li><a href="{esc(l["url"])}">{esc(l["label"])}</a> '
|
||||
f'<small>— from {esc(l["source_title"])}</small></li>'
|
||||
)
|
||||
trusted_html = (
|
||||
f'<details class="trusted">'
|
||||
f'<summary>from your trusted sites ({len(trusted)})</summary>'
|
||||
f'<ul>{items}</ul>'
|
||||
f'</details>'
|
||||
)
|
||||
|
||||
db.close()
|
||||
return _respond(
|
||||
f'<h1><a href="/">{esc(name)}</a></h1>'
|
||||
f'<form method="get" action="/">'
|
||||
f'<input name="q" value="{esc(q)}" placeholder="search your index" size="40">'
|
||||
f' <button type="submit">search</button>'
|
||||
f'</form>'
|
||||
f'<p>{count} page(s) indexed.'
|
||||
f' <a href="/add">+ add url</a>'
|
||||
f' | <a href="/pages">browse</a>'
|
||||
f' | <a href="/subscriptions">subscriptions</a>'
|
||||
f' | <a href="/style">customize</a></p>'
|
||||
f'<hr>{result_html}{trusted_html}'
|
||||
)
|
||||
|
||||
|
||||
def handle_add_form(msg=""):
|
||||
return _respond(
|
||||
f"<h1>add url</h1>"
|
||||
f'<form method="post" action="/add">'
|
||||
f'<input name="url" placeholder="https://example.com" size="50"><br><br>'
|
||||
f'<input name="note" placeholder="why are you saving this? (optional)" size="50"><br><br>'
|
||||
f'<button type="submit">index</button>'
|
||||
f"</form>"
|
||||
f"<p>{msg}</p>"
|
||||
f'<a href="/">back</a>'
|
||||
)
|
||||
|
||||
|
||||
def handle_add_submit(body):
|
||||
url = body.get("url", [""])[0].strip()
|
||||
note = body.get("note", [""])[0].strip()
|
||||
if not url:
|
||||
return handle_add_form("URL is required.")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return handle_add_form("URL must start with http:// or https://")
|
||||
try:
|
||||
title = index_url(url, note)
|
||||
return handle_add_form(f'Indexed: <a href="{esc(url)}">{esc(title)}</a>')
|
||||
except Exception as e:
|
||||
return handle_add_form(f"Error: {esc(str(e))}")
|
||||
|
||||
|
||||
def handle_pages():
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall()
|
||||
db.close()
|
||||
items = ""
|
||||
for r in rows:
|
||||
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
|
||||
items += (
|
||||
f'<li>{esc(r["title"])}{note_html} '
|
||||
f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small> '
|
||||
f'<a href="/edit/{r["id"]}">edit</a> '
|
||||
f'<a href="/delete/{r["id"]}">remove</a></li>'
|
||||
)
|
||||
return _respond(
|
||||
f"<h1>indexed pages ({len(rows)})</h1>"
|
||||
f"<ul>{items}</ul>"
|
||||
f'<p><a href="/export">export</a> | <a href="/import">import</a></p>'
|
||||
f'<a href="/">back</a>'
|
||||
)
|
||||
|
||||
|
||||
def handle_edit_form(page_id, msg=""):
|
||||
db = get_db()
|
||||
row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone()
|
||||
db.close()
|
||||
if not row:
|
||||
return _error(404)
|
||||
return _respond(
|
||||
f"<h1>edit note</h1>"
|
||||
f"<p><b>{esc(row['title'])}</b><br>"
|
||||
f"<small>{esc(row['url'])}</small></p>"
|
||||
f'<form method="post" action="/edit/{row["id"]}">'
|
||||
f'<input name="note" value="{esc(row["note"])}" placeholder="why did you save this?" size="50"><br><br>'
|
||||
f'<button type="submit">save</button>'
|
||||
f"</form>"
|
||||
f"<p>{msg}</p>"
|
||||
f'<a href="/pages">back</a>'
|
||||
)
|
||||
|
||||
|
||||
def handle_edit_submit(page_id, body):
|
||||
note = body.get("note", [""])[0].strip()
|
||||
db = get_db()
|
||||
db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return _redirect("/pages")
|
||||
|
||||
|
||||
def handle_delete(page_id):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
||||
db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return _redirect("/pages")
|
||||
|
||||
|
||||
def handle_bookmark(query):
|
||||
url = query.get("url", [""])[0].strip()
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
return _text_response("error: invalid url", headers={"Access-Control-Allow-Origin": "*"})
|
||||
try:
|
||||
title = index_url(url)
|
||||
msg = f"ok: {title}"
|
||||
except Exception as e:
|
||||
msg = f"error: {e}"
|
||||
return _text_response(msg, headers={"Access-Control-Allow-Origin": "*"})
|
||||
|
||||
|
||||
def handle_export():
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT url, title, note FROM pages ORDER BY id").fetchall()
|
||||
db.close()
|
||||
data = [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in rows]
|
||||
return _json_response(data, headers={"Content-Disposition": "attachment; filename=tinyweb-export.json"})
|
||||
|
||||
|
||||
def handle_import_form(msg=""):
|
||||
return _respond(
|
||||
f"<h1>import</h1>"
|
||||
f"<p>Paste the contents of a tinyweb export file (JSON).</p>"
|
||||
f'<form method="post" action="/import">'
|
||||
f'<textarea name="data" rows="12" cols="60" placeholder=\'[{{"url": "...", "note": "..."}}]\'></textarea><br><br>'
|
||||
f'<button type="submit">import</button>'
|
||||
f"</form>"
|
||||
f"<p>{msg}</p>"
|
||||
f'<a href="/pages">back</a>'
|
||||
)
|
||||
|
||||
|
||||
def handle_import_submit(body):
|
||||
raw = body.get("data", [""])[0].strip()
|
||||
if not raw:
|
||||
return handle_import_form("Paste JSON data.")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return handle_import_form("Invalid JSON.")
|
||||
if not isinstance(data, list):
|
||||
return handle_import_form("Expected a JSON array.")
|
||||
|
||||
imported = 0
|
||||
errors = 0
|
||||
for entry in data:
|
||||
url = entry.get("url", "").strip()
|
||||
note = entry.get("note", "").strip()
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
index_url(url, note)
|
||||
imported += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
|
||||
return handle_import_form(f"Imported {imported} page(s). {errors} error(s).")
|
||||
|
||||
|
||||
def handle_style_form(msg="", gateway_host=""):
|
||||
css = get_setting("custom_css")
|
||||
name = get_site_name()
|
||||
sharing = get_setting("sharing_enabled", "0")
|
||||
checked = " checked" if sharing == "1" else ""
|
||||
host = gateway_host or "localhost:8080"
|
||||
return _respond(
|
||||
f"<h1>customize</h1>"
|
||||
f"<h2>name your search engine</h2>"
|
||||
f'<form method="post" action="/style">'
|
||||
f'<input name="site_name" value="{esc(name)}" placeholder="tinyweb" size="30"><br><br>'
|
||||
f"<h2>sharing</h2>"
|
||||
f'<label><input type="checkbox" name="sharing_enabled" value="1"{checked}>'
|
||||
f" share your site list publicly at /api/sites</label><br><br>"
|
||||
f"<h2>custom css</h2>"
|
||||
f"<p>Some classes you can target:</p>"
|
||||
f"<pre>"
|
||||
f"body - page background, font\n"
|
||||
f"h1 - page titles\n"
|
||||
f"input, button - search bar\n"
|
||||
f"a - links\n"
|
||||
f".result - each search result\n"
|
||||
f".note - your notes on results\n"
|
||||
f".trusted - trusted sites dropdown\n"
|
||||
f"small - url text\n"
|
||||
f"ul, li - browse page list"
|
||||
f"</pre>"
|
||||
f'<textarea name="css" rows="16" cols="60">{esc(css)}</textarea><br><br>'
|
||||
f'<button type="submit">save</button>'
|
||||
f"</form>"
|
||||
f"<h2>bookmarklet</h2>"
|
||||
f"<p>Drag this link to your bookmarks bar. Click it on any page to index it instantly.</p>"
|
||||
f'<p><a href="javascript:void(fetch(\'http://{esc(host)}/bookmark?url=\'+encodeURIComponent(location.href)).then(r=>r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}</a></p>'
|
||||
f"<p>{msg}</p>"
|
||||
f'<a href="/">back</a>'
|
||||
)
|
||||
|
||||
|
||||
def handle_style_submit(body):
|
||||
css = body.get("css", [""])[0]
|
||||
name = body.get("site_name", ["tinyweb"])[0].strip()
|
||||
sharing = "1" if body.get("sharing_enabled") else "0"
|
||||
set_setting("custom_css", css)
|
||||
set_setting("site_name", name or "tinyweb")
|
||||
set_setting("sharing_enabled", sharing)
|
||||
return handle_style_form("Saved.")
|
||||
|
||||
|
||||
def handle_api_sites():
|
||||
if get_setting("sharing_enabled", "0") != "1":
|
||||
return _json_response(
|
||||
{"error": "sharing disabled"},
|
||||
status=403,
|
||||
headers={"Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT url, title, note FROM pages ORDER BY id DESC").fetchall()
|
||||
db.close()
|
||||
data = {
|
||||
"name": get_site_name(),
|
||||
"sites": [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in rows],
|
||||
}
|
||||
return _json_response(data, headers={"Access-Control-Allow-Origin": "*"})
|
||||
|
||||
|
||||
def handle_subscriptions(msg=""):
|
||||
db = get_db()
|
||||
subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
|
||||
db.close()
|
||||
items = ""
|
||||
for s in subs:
|
||||
auto_label = "on" if s["auto_sync"] else "off"
|
||||
last = s["last_sync"] or "never"
|
||||
items += (
|
||||
f'<tr>'
|
||||
f'<td><b>{esc(s["name"] or "unknown")}</b><br><small>{esc(s["url"])}</small></td>'
|
||||
f'<td>{esc(last)}</td>'
|
||||
f'<td>'
|
||||
f'<form method="post" action="/subscriptions/autosync/{s["id"]}" style="display:inline">'
|
||||
f'<button>auto-sync: {auto_label}</button></form>'
|
||||
f'</td>'
|
||||
f'<td>'
|
||||
f'<a href="/subscriptions/browse/{s["id"]}">browse</a> '
|
||||
f'<form method="post" action="/subscriptions/sync/{s["id"]}" style="display:inline">'
|
||||
f'<button>sync now</button></form> '
|
||||
f'<form method="post" action="/subscriptions/delete/{s["id"]}" style="display:inline">'
|
||||
f'<button>remove</button></form>'
|
||||
f'</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
table = ""
|
||||
if subs:
|
||||
table = (
|
||||
f'<table><tr><th>instance</th><th>last sync</th><th>auto-sync</th><th>actions</th></tr>'
|
||||
f'{items}</table>'
|
||||
f'<form method="post" action="/subscriptions/syncall">'
|
||||
f'<button>sync all</button></form>'
|
||||
)
|
||||
return _respond(
|
||||
f"<h1>subscriptions</h1>"
|
||||
f'<form method="post" action="/subscriptions/add">'
|
||||
f'<input name="url" placeholder="http://friend:5001" size="40"> '
|
||||
f'<button>subscribe</button>'
|
||||
f'</form>'
|
||||
f'<p>{msg}</p>'
|
||||
f'<hr>{table}'
|
||||
f'<br><a href="/">back</a>'
|
||||
)
|
||||
|
||||
|
||||
def handle_subscription_add(body):
|
||||
url = body.get("url", [""])[0].strip().rstrip("/")
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
return handle_subscriptions("URL must start with http:// or https://")
|
||||
try:
|
||||
resp = requests.get(f"{url}/api/sites", timeout=5)
|
||||
if resp.status_code == 403:
|
||||
return handle_subscriptions("That instance has sharing disabled.")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
name = data.get("name", "")
|
||||
except Exception as e:
|
||||
return handle_subscriptions(f"Could not reach that instance: {esc(str(e))}")
|
||||
db = get_db()
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT INTO subscriptions (url, name) VALUES (?, ?) "
|
||||
"ON CONFLICT(url) DO UPDATE SET name=excluded.name",
|
||||
(url, name),
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return handle_subscriptions(f"Subscribed to {esc(name or url)}.")
|
||||
|
||||
|
||||
def handle_subscription_browse(sub_id):
|
||||
db = get_db()
|
||||
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
|
||||
if not sub:
|
||||
db.close()
|
||||
return _error(404)
|
||||
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
|
||||
db.close()
|
||||
try:
|
||||
resp = requests.get(f"{sub['url']}/api/sites", timeout=5)
|
||||
if resp.status_code == 403:
|
||||
return handle_subscriptions("That instance has sharing disabled.")
|
||||
resp.raise_for_status()
|
||||
sites = resp.json().get("sites", [])
|
||||
except Exception as e:
|
||||
return handle_subscriptions(f"Could not fetch sites: {esc(str(e))}")
|
||||
|
||||
new_items = ""
|
||||
existing_items = ""
|
||||
new_count = 0
|
||||
for s in sites:
|
||||
if s["url"] in local_urls:
|
||||
existing_items += (
|
||||
f'<li style="opacity:0.5">{esc(s["title"])} '
|
||||
f'<small>({esc(s["url"])})</small> — already indexed</li>'
|
||||
)
|
||||
else:
|
||||
new_count += 1
|
||||
note_html = f' — <em>{esc(s["note"])}</em>' if s.get("note") else ""
|
||||
new_items += (
|
||||
f'<li><label><input type="checkbox" name="urls" value="{esc(s["url"])}">'
|
||||
f' {esc(s["title"])}{note_html}'
|
||||
f' <small>({esc(s["url"])})</small></label></li>'
|
||||
)
|
||||
|
||||
buttons = ""
|
||||
if new_count:
|
||||
buttons = '<button>import selected</button> <button name="import_all" value="1">import all new</button>'
|
||||
return _respond(
|
||||
f'<h1>browsing: {esc(sub["name"] or sub["url"])}</h1>'
|
||||
f'<p>{len(sites)} site(s) available, {new_count} new</p>'
|
||||
f'<form method="post" action="/subscriptions/pick">'
|
||||
f'<input type="hidden" name="sub_id" value="{sub_id}">'
|
||||
f'<ul>{new_items}</ul>'
|
||||
f'{buttons}'
|
||||
f'</form>'
|
||||
f'<h3>already indexed</h3><ul>{existing_items}</ul>'
|
||||
f'<a href="/subscriptions">back</a>'
|
||||
)
|
||||
|
||||
|
||||
def handle_subscription_pick(body):
|
||||
sub_id = body.get("sub_id", [""])[0]
|
||||
import_all = body.get("import_all", [""])[0]
|
||||
|
||||
if import_all:
|
||||
db = get_db()
|
||||
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
|
||||
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
|
||||
db.close()
|
||||
if not sub:
|
||||
return handle_subscriptions("Subscription not found.")
|
||||
try:
|
||||
resp = requests.get(f"{sub['url']}/api/sites", timeout=5)
|
||||
resp.raise_for_status()
|
||||
sites = resp.json().get("sites", [])
|
||||
except Exception as e:
|
||||
return handle_subscriptions(f"Error: {esc(str(e))}")
|
||||
urls = [s["url"] for s in sites if s["url"] not in local_urls]
|
||||
else:
|
||||
urls = body.get("urls", [])
|
||||
|
||||
if not urls:
|
||||
return handle_subscriptions("No sites selected.")
|
||||
|
||||
imported = 0
|
||||
errors = 0
|
||||
for url in urls:
|
||||
try:
|
||||
index_url(url)
|
||||
imported += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
return handle_subscriptions(f"Imported {imported} page(s). {errors} error(s).")
|
||||
|
||||
|
||||
def handle_subscription_sync(sub_id):
|
||||
db = get_db()
|
||||
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
|
||||
if not sub:
|
||||
db.close()
|
||||
return handle_subscriptions("Subscription not found.")
|
||||
try:
|
||||
resp = requests.get(f"{sub['url']}/api/sites", timeout=5)
|
||||
if resp.status_code == 403:
|
||||
db.close()
|
||||
return handle_subscriptions("That instance has sharing disabled.")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
sites = data.get("sites", [])
|
||||
remote_name = data.get("name", sub["name"])
|
||||
except Exception as e:
|
||||
db.close()
|
||||
return handle_subscriptions(f"Could not sync: {esc(str(e))}")
|
||||
|
||||
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
|
||||
synced = 0
|
||||
for s in sites:
|
||||
if s["url"] in local_urls:
|
||||
continue
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT INTO pages (url, title, body, note) VALUES (?, ?, ?, ?)",
|
||||
(s["url"], s["title"], f"[synced from {remote_name}]", s.get("note", "")),
|
||||
)
|
||||
synced += 1
|
||||
except Exception:
|
||||
pass
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return handle_subscriptions(f"Synced {synced} new site(s) from {esc(remote_name)}.")
|
||||
|
||||
|
||||
def handle_subscription_autosync(sub_id):
|
||||
db = get_db()
|
||||
db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return _redirect("/subscriptions")
|
||||
|
||||
|
||||
def handle_subscription_delete(sub_id):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return _redirect("/subscriptions")
|
||||
|
||||
|
||||
def handle_subscription_syncall():
|
||||
db = get_db()
|
||||
subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall()
|
||||
db.close()
|
||||
if not subs:
|
||||
return handle_subscriptions("No subscriptions have auto-sync enabled.")
|
||||
total = 0
|
||||
for sub in subs:
|
||||
try:
|
||||
resp = requests.get(f"{sub['url']}/api/sites", timeout=5)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
data = resp.json()
|
||||
sites = data.get("sites", [])
|
||||
remote_name = data.get("name", sub["name"])
|
||||
db = get_db()
|
||||
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
|
||||
for s in sites:
|
||||
if s["url"] in local_urls:
|
||||
continue
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT INTO pages (url, title, body, note) VALUES (?, ?, ?, ?)",
|
||||
(s["url"], s["title"], f"[synced from {remote_name}]", s.get("note", "")),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub["id"]))
|
||||
db.commit()
|
||||
db.close()
|
||||
total += 1
|
||||
except Exception:
|
||||
pass
|
||||
return handle_subscriptions(f"Synced {total} subscription(s).")
|
||||
|
||||
|
||||
# --- Dispatcher ---
|
||||
|
||||
|
||||
def dispatch_request(data):
|
||||
method = data.get("method", "GET")
|
||||
path = data.get("path", "/")
|
||||
query = data.get("query", {})
|
||||
body = data.get("body", {})
|
||||
gateway_host = data.get("gateway_host", "")
|
||||
|
||||
def extract_id(prefix):
|
||||
try:
|
||||
return int(path[len(prefix):])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
if method == "GET":
|
||||
if path == "/":
|
||||
return handle_search(query)
|
||||
elif path == "/add":
|
||||
return handle_add_form()
|
||||
elif path == "/pages":
|
||||
return handle_pages()
|
||||
elif path.startswith("/edit/"):
|
||||
pid = extract_id("/edit/")
|
||||
return handle_edit_form(pid) if pid is not None else _error(400)
|
||||
elif path.startswith("/delete/"):
|
||||
pid = extract_id("/delete/")
|
||||
return handle_delete(pid) if pid is not None else _error(400)
|
||||
elif path == "/bookmark":
|
||||
return handle_bookmark(query)
|
||||
elif path == "/style":
|
||||
return handle_style_form(gateway_host=gateway_host)
|
||||
elif path == "/export":
|
||||
return handle_export()
|
||||
elif path == "/import":
|
||||
return handle_import_form()
|
||||
elif path == "/api/sites":
|
||||
return handle_api_sites()
|
||||
elif path == "/subscriptions":
|
||||
return handle_subscriptions()
|
||||
elif path.startswith("/subscriptions/browse/"):
|
||||
sid = extract_id("/subscriptions/browse/")
|
||||
return handle_subscription_browse(sid) if sid is not None else _error(400)
|
||||
elif method == "POST":
|
||||
if path == "/add":
|
||||
return handle_add_submit(body)
|
||||
elif path.startswith("/edit/"):
|
||||
pid = extract_id("/edit/")
|
||||
return handle_edit_submit(pid, body) if pid is not None else _error(400)
|
||||
elif path == "/style":
|
||||
return handle_style_submit(body)
|
||||
elif path == "/import":
|
||||
return handle_import_submit(body)
|
||||
elif path == "/subscriptions/add":
|
||||
return handle_subscription_add(body)
|
||||
elif path == "/subscriptions/pick":
|
||||
return handle_subscription_pick(body)
|
||||
elif path.startswith("/subscriptions/sync/"):
|
||||
sid = extract_id("/subscriptions/sync/")
|
||||
return handle_subscription_sync(sid) if sid is not None else _error(400)
|
||||
elif path.startswith("/subscriptions/autosync/"):
|
||||
sid = extract_id("/subscriptions/autosync/")
|
||||
return handle_subscription_autosync(sid) if sid is not None else _error(400)
|
||||
elif path.startswith("/subscriptions/delete/"):
|
||||
sid = extract_id("/subscriptions/delete/")
|
||||
return handle_subscription_delete(sid) if sid is not None else _error(400)
|
||||
elif path == "/subscriptions/syncall":
|
||||
return handle_subscription_syncall()
|
||||
|
||||
return _error(404)
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
requests
|
||||
beautifulsoup4
|
||||
rns
|
||||
|
|
|
|||
21
templates.py
Normal file
21
templates.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import html
|
||||
from db import get_setting
|
||||
|
||||
|
||||
def esc(s):
|
||||
return html.escape(str(s))
|
||||
|
||||
|
||||
def snippet(text, query, ctx=80):
|
||||
pos = text.lower().find(query.lower())
|
||||
if pos == -1:
|
||||
return text[:200]
|
||||
start = max(0, pos - ctx)
|
||||
end = min(len(text), pos + len(query) + ctx)
|
||||
return ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "")
|
||||
|
||||
|
||||
def wrap_page(body_html):
|
||||
css = get_setting("custom_css")
|
||||
style = f"<style>{css}</style>" if css else ""
|
||||
return f"<html><head>{style}</head><body>{body_html}</body></html>"
|
||||
Loading…
Add table
Add a link
Reference in a new issue