diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..799f1c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +tinyweb_identity +index.db diff --git a/app.py b/app.py index dc83ae4..890bf0a 100644 --- a/app.py +++ b/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"" if css else "" - return f"
{style}{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("No results in your index.
" - - # 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'{count} page(s) indexed.' - f' + add url' - f' | browse' - f' | customize
' - f'{msg}
" - f'back' - ) - - 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: {esc(title)}') - 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' — {esc(r["note"])}' if r["note"] else "" - items += ( - f'{esc(row['title'])}
"
- f"{esc(row['url'])}
{msg}
" - f'back' - ) - - def handle_edit_submit(self, path, params): - try: - page_id = int(path.split("/")[-1]) - except ValueError: - return self.respond("Paste the contents of a tinyweb export file (JSON).
" - f'" - f"{msg}
" - f'back' - ) - - 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"Drag this link to your bookmarks bar. Click it on any page to index it instantly.
" - f'r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}
' - f"{msg}
" - f'back' - ) - - 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() diff --git a/db.py b/db.py new file mode 100644 index 0000000..903f824 --- /dev/null +++ b/db.py @@ -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 diff --git a/gateway.py b/gateway.py new file mode 100644 index 0000000..0bde2d3 --- /dev/null +++ b/gateway.py @@ -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.pyNo results in your index.
" + + # 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'{count} page(s) indexed.' + f' + add url' + f' | browse' + f' | subscriptions' + f' | customize
' + f'{msg}
" + f'back' + ) + + +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: {esc(title)}') + 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' — {esc(r["note"])}' if r["note"] else "" + items += ( + f'{esc(row['title'])}
"
+ f"{esc(row['url'])}
{msg}
" + f'back' + ) + + +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"Paste the contents of a tinyweb export file (JSON).
" + f'" + f"{msg}
" + f'back' + ) + + +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"Drag this link to your bookmarks bar. Click it on any page to index it instantly.
" + f'' + f"{msg}
" + f'back' + ) + + +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'| instance | last sync | auto-sync | actions |
|---|
{msg}
' + f'{len(sites)} site(s) available, {new_count} new
' + f'' + f'