wired up mesh subscriptions + search

- Subscriptions now use Reticulum destination hashes instead of HTTP URLs
- All subscription syncing happens over encrypted RNS links (rns_client.py)
- Add remote_pages table for synced content from subscriptions
- Search results now include pages from synced subscriptions, grouped by source
- Remove HTTP dependency from subscription handlers
This commit is contained in:
lichenblankie 2026-03-25 22:51:22 -07:00
parent 4b4e7e8081
commit 7ccaf93404
3 changed files with 201 additions and 56 deletions

View file

@ -1,9 +1,9 @@
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
from rns_client import fetch_remote_sites
def _respond(body_html, status=200):
@ -112,7 +112,42 @@ def handle_search(query):
f'</details>'
)
# search synced pages from subscriptions
remote_rows = db.execute(
"SELECT rp.url, rp.title, rp.note, s.name AS source_name "
"FROM remote_pages_fts rpf "
"JOIN remote_pages rp ON rpf.rowid = rp.id "
"JOIN subscriptions s ON rp.subscription_id = s.id "
"WHERE remote_pages_fts MATCH ? ORDER BY rank LIMIT 50",
(q,),
).fetchall()
remote_html = ""
if q and remote_rows:
# group by source
by_source = {}
for r in remote_rows:
source = r["source_name"] or "unknown"
by_source.setdefault(source, []).append(r)
for source, items in by_source.items():
source_items = ""
for r in items:
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
source_items += (
f'<li><a href="{esc(r["url"])}">{esc(r["title"])}</a>'
f'{note_html} <small>({esc(r["url"])})</small></li>'
)
remote_html += (
f'<details class="remote" open>'
f'<summary>from {esc(source)} ({len(items)})</summary>'
f'<ul>{source_items}</ul>'
f'</details>'
)
db.close()
sub_count = ""
if q and remote_rows:
sub_count = f" + {len(remote_rows)} from subscriptions"
return _respond(
f'<h1><a href="/">{esc(name)}</a></h1>'
f'<form method="get" action="/">'
@ -124,7 +159,7 @@ def handle_search(query):
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}'
f'<hr>{result_html}{trusted_html}{remote_html}'
)
@ -348,7 +383,7 @@ def handle_subscriptions(msg=""):
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><b>{esc(s["name"] or "unknown")}</b><br><small>{esc(s["dest_hash"])}</small></td>'
f'<td>{esc(last)}</td>'
f'<td>'
f'<form method="post" action="/subscriptions/autosync/{s["id"]}" style="display:inline">'
@ -374,7 +409,7 @@ def handle_subscriptions(msg=""):
return _respond(
f"<h1>subscriptions</h1>"
f'<form method="post" action="/subscriptions/add">'
f'<input name="url" placeholder="http://friend:5001" size="40"> '
f'<input name="dest_hash" placeholder="destination hash" size="40"> '
f'<button>subscribe</button>'
f'</form>'
f'<p>{msg}</p>'
@ -384,29 +419,31 @@ def handle_subscriptions(msg=""):
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://")
dest_hash = body.get("dest_hash", [""])[0].strip().replace("<", "").replace(">", "")
if not dest_hash or len(dest_hash) != 32:
return handle_subscriptions("Enter a valid 32-character destination hash.")
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()
int(dest_hash, 16)
except ValueError:
return handle_subscriptions("Invalid destination hash (must be hex).")
try:
data = fetch_remote_sites(dest_hash)
name = data.get("name", "")
except PermissionError:
return handle_subscriptions("That instance has sharing disabled.")
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),
"INSERT INTO subscriptions (dest_hash, name) VALUES (?, ?) "
"ON CONFLICT(dest_hash) DO UPDATE SET name=excluded.name",
(dest_hash, name),
)
db.commit()
finally:
db.close()
return handle_subscriptions(f"Subscribed to {esc(name or url)}.")
return handle_subscriptions(f"Subscribed to {esc(name or dest_hash)}.")
def handle_subscription_browse(sub_id):
@ -416,15 +453,24 @@ def handle_subscription_browse(sub_id):
db.close()
return _error(404)
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
# Use locally synced data if available, otherwise fetch live
remote_rows = db.execute(
"SELECT url, title, note FROM remote_pages WHERE subscription_id = ?",
(sub_id,),
).fetchall()
db.close()
try:
resp = requests.get(f"{sub['url']}/api/sites", timeout=5)
if resp.status_code == 403:
if remote_rows:
sites = [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in remote_rows]
else:
try:
data = fetch_remote_sites(sub["dest_hash"])
sites = data.get("sites", [])
except PermissionError:
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))}")
except Exception as e:
return handle_subscriptions(f"Could not fetch sites: {esc(str(e))}")
new_items = ""
existing_items = ""
@ -448,7 +494,7 @@ def handle_subscription_browse(sub_id):
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'<h1>browsing: {esc(sub["name"] or sub["dest_hash"])}</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}">'
@ -466,18 +512,12 @@ def handle_subscription_pick(body):
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())
remote = db.execute(
"SELECT url FROM remote_pages WHERE subscription_id = ?", (sub_id,)
).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]
urls = [r["url"] for r in remote if r["url"] not in local_urls]
else:
urls = body.get("urls", [])
@ -502,27 +542,24 @@ def handle_subscription_sync(sub_id):
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()
data = fetch_remote_sites(sub["dest_hash"])
sites = data.get("sites", [])
remote_name = data.get("name", sub["name"])
except PermissionError:
db.close()
return handle_subscriptions("That instance has sharing disabled.")
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())
# Clear old remote pages for this subscription and re-insert
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
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", "")),
"INSERT INTO remote_pages (subscription_id, url, title, note) VALUES (?, ?, ?, ?)",
(sub_id, s["url"], s["title"], s.get("note", "")),
)
synced += 1
except Exception:
@ -531,7 +568,7 @@ def handle_subscription_sync(sub_id):
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)}.")
return handle_subscriptions(f"Synced {synced} site(s) from {esc(remote_name)}.")
def handle_subscription_autosync(sub_id):
@ -559,21 +596,16 @@ def handle_subscription_syncall():
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()
data = fetch_remote_sites(sub["dest_hash"])
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())
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub["id"],))
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", "")),
"INSERT INTO remote_pages (subscription_id, url, title, note) VALUES (?, ?, ?, ?)",
(sub["id"], s["url"], s["title"], s.get("note", "")),
)
except Exception:
pass