Date: Fri, 27 Mar 2026 15:44:07 -0700
Subject: [PATCH 059/194] improved snippet extraction (heuristic)
- Case-insensitive meta description extraction (fixes sites like Lemmy
with capitalized "Description" meta name)
- Strip aside and noscript tags for cleaner body text
- Extract paragraph text separately for better sentence quality
- Prefer sentences mentioning the site name, then first quality
paragraph, then title as fallback
- Skip meta descriptions under 20 chars (e.g. just "Lemmy")
- Remove embedding/centroid dependency from summary generation
---
db.py | 129 +++++++++++++++++++++++++++++++---------------------------
1 file changed, 69 insertions(+), 60 deletions(-)
diff --git a/db.py b/db.py
index a6d8008..f31473f 100644
--- a/db.py
+++ b/db.py
@@ -317,88 +317,97 @@ def fetch_page(url):
label = a.get_text(strip=True) or href
links.append((href, label[:200]))
- # Extract meta description before stripping tags
+ # Extract meta description before stripping tags (case-insensitive)
meta_desc = ""
- meta_tag = soup.find("meta", attrs={"name": "description"})
- if meta_tag and meta_tag.get("content"):
- meta_desc = meta_tag["content"].strip()
- if not meta_desc:
- # Try og:description as fallback
- og_tag = soup.find("meta", attrs={"property": "og:description"})
- if og_tag and og_tag.get("content"):
- meta_desc = og_tag["content"].strip()
+ for m in soup.find_all("meta"):
+ name = (m.get("name") or "").lower()
+ prop = (m.get("property") or "").lower()
+ content = (m.get("content") or "").strip()
+ if not content:
+ continue
+ if name == "description" and len(content) > len(meta_desc):
+ meta_desc = content
+ elif prop == "og:description" and not meta_desc:
+ meta_desc = content
- for tag in soup(["script", "style", "nav", "footer", "header", "noscript"]):
+ for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "aside"]):
tag.decompose()
title = soup.title.string.strip() if soup.title and soup.title.string else url
+
+ # Extract paragraph text for better summary generation
+ paragraphs = []
+ for p in soup.find_all("p"):
+ text = p.get_text(strip=True)
+ if len(text) >= 40:
+ paragraphs.append(text)
+
body = soup.get_text(separator=" ", strip=True)
- return title, body, links, meta_desc
+ return title, body, links, meta_desc, paragraphs
-def _generate_summary(title, body):
- """Generate a summary from body text using centroid extractive method.
+def _generate_summary(title, body, paragraphs=None):
+ """Generate a summary by extracting the best sentence from the page.
- Filters out UI debris, embeds remaining sentences, finds the one
- closest to the centroid (most representative of the page).
+ Priority: sentence mentioning the site name > first paragraph sentence
+ > first body sentence > title.
"""
import re
- # Split on sentence boundaries
- raw = re.split(r'(?<=[.!?])\s+', body)
- sentences = []
noise_patterns = re.compile(
r'arrow-|fedilink|message-square|link-external|'
r'skip to|cookie|subscribe|sign up|log in|'
r'privacy policy|terms of|©|\bads?\b',
re.IGNORECASE
)
- for s in raw:
- s = s.strip()
- if len(s) < 40:
- continue
- words = s.split()
- if len(words) < 7:
- continue
- # Skip if mostly non-alpha (icons, arrows, encoded chars)
- alpha_chars = sum(1 for c in s if c.isalpha() or c == ' ')
- if alpha_chars < len(s) * 0.6:
- continue
- # Skip nav/menu patterns
- if s.count('|') > 2 or s.count('·') > 2 or s.count('►') > 0:
- continue
- # Skip UI debris
- if noise_patterns.search(s):
- continue
- sentences.append(s)
+
+ def _filter_sentences(raw):
+ result = []
+ for s in raw:
+ s = s.strip()
+ if len(s) < 40 or len(s.split()) < 7:
+ continue
+ alpha_chars = sum(1 for c in s if c.isalpha() or c == ' ')
+ if alpha_chars < len(s) * 0.6:
+ continue
+ if s.count('|') > 2 or s.count('·') > 2 or s.count('►') > 0:
+ continue
+ if noise_patterns.search(s):
+ continue
+ result.append(s)
+ return result
+
+ # Prefer sentences from tags (actual content, not UI)
+ sentences = []
+ if paragraphs:
+ raw = []
+ for p in paragraphs:
+ raw.extend(re.split(r'(?<=[.!?])\s+', p))
+ sentences = _filter_sentences(raw)
+
+ # Fall back to full body text
+ if not sentences:
+ raw = re.split(r'(?<=[.!?])\s+', body)
+ sentences = _filter_sentences(raw)
if not sentences:
- # Last resort: take the first chunk of body that looks like prose
- clean = re.sub(r'\s+', ' ', body).strip()
- return clean[:160] + "..." if len(clean) > 160 else clean
- if len(sentences) == 1:
- s = sentences[0]
- return s[:200] if len(s) > 200 else s
- try:
- from embeddings import embed
- import numpy as np
- embs = embed(sentences[:50]) # cap to avoid embedding too many
- centroid = embs.mean(axis=0, keepdims=True)
- centroid = centroid / max(np.linalg.norm(centroid), 1e-12)
- scores = (embs @ centroid.T).flatten()
- best_idx = int(np.argmax(scores))
- result = sentences[best_idx]
- # Try to add a second sentence if it fits
- if best_idx + 1 < len(sentences) and len(result) + len(sentences[best_idx + 1]) + 1 <= 200:
- result += " " + sentences[best_idx + 1]
- return result[:200] if len(result) > 200 else result
- except Exception:
- return sentences[0][:200]
+ return title[:200] if title else ""
+
+ # Prefer a sentence that mentions the site name
+ if title:
+ title_words = [w.lower() for w in re.split(r'\W+', title) if len(w) >= 3]
+ for s in sentences:
+ s_lower = s.lower()
+ if sum(1 for w in title_words if w in s_lower) >= max(1, len(title_words) // 2):
+ return s[:200]
+
+ # Otherwise use the first quality sentence
+ return sentences[0][:200]
def index_url(url, note=""):
url = clean_url(url)
- title, body, links, meta_desc = fetch_page(url)
- # Use meta description if available, otherwise generate from body
- summary = meta_desc if meta_desc else _generate_summary(title, body)
+ title, body, links, meta_desc, paragraphs = fetch_page(url)
+ # Use meta description if available and meaningful, otherwise generate from body
+ summary = meta_desc if meta_desc and len(meta_desc) > 20 else _generate_summary(title, body, paragraphs)
db = get_db()
try:
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
From 0495f81a84d7429864ba66c4e8713a24d5c3cb3c Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 27 Mar 2026 15:44:07 -0700
Subject: [PATCH 060/194] improved snippet extraction (heuristic)
- Case-insensitive meta description extraction (fixes sites like Lemmy
with capitalized "Description" meta name)
- Strip aside and noscript tags for cleaner body text
- Extract paragraph text separately for better sentence quality
- Prefer sentences mentioning the site name, then first quality
paragraph, then title as fallback
- Skip meta descriptions under 20 chars (e.g. just "Lemmy")
- Remove embedding/centroid dependency from summary generation
---
db.py | 129 +++++++++++++++++++++++++++++++---------------------------
1 file changed, 69 insertions(+), 60 deletions(-)
diff --git a/db.py b/db.py
index a6d8008..f31473f 100644
--- a/db.py
+++ b/db.py
@@ -317,88 +317,97 @@ def fetch_page(url):
label = a.get_text(strip=True) or href
links.append((href, label[:200]))
- # Extract meta description before stripping tags
+ # Extract meta description before stripping tags (case-insensitive)
meta_desc = ""
- meta_tag = soup.find("meta", attrs={"name": "description"})
- if meta_tag and meta_tag.get("content"):
- meta_desc = meta_tag["content"].strip()
- if not meta_desc:
- # Try og:description as fallback
- og_tag = soup.find("meta", attrs={"property": "og:description"})
- if og_tag and og_tag.get("content"):
- meta_desc = og_tag["content"].strip()
+ for m in soup.find_all("meta"):
+ name = (m.get("name") or "").lower()
+ prop = (m.get("property") or "").lower()
+ content = (m.get("content") or "").strip()
+ if not content:
+ continue
+ if name == "description" and len(content) > len(meta_desc):
+ meta_desc = content
+ elif prop == "og:description" and not meta_desc:
+ meta_desc = content
- for tag in soup(["script", "style", "nav", "footer", "header", "noscript"]):
+ for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "aside"]):
tag.decompose()
title = soup.title.string.strip() if soup.title and soup.title.string else url
+
+ # Extract paragraph text for better summary generation
+ paragraphs = []
+ for p in soup.find_all("p"):
+ text = p.get_text(strip=True)
+ if len(text) >= 40:
+ paragraphs.append(text)
+
body = soup.get_text(separator=" ", strip=True)
- return title, body, links, meta_desc
+ return title, body, links, meta_desc, paragraphs
-def _generate_summary(title, body):
- """Generate a summary from body text using centroid extractive method.
+def _generate_summary(title, body, paragraphs=None):
+ """Generate a summary by extracting the best sentence from the page.
- Filters out UI debris, embeds remaining sentences, finds the one
- closest to the centroid (most representative of the page).
+ Priority: sentence mentioning the site name > first paragraph sentence
+ > first body sentence > title.
"""
import re
- # Split on sentence boundaries
- raw = re.split(r'(?<=[.!?])\s+', body)
- sentences = []
noise_patterns = re.compile(
r'arrow-|fedilink|message-square|link-external|'
r'skip to|cookie|subscribe|sign up|log in|'
r'privacy policy|terms of|©|\bads?\b',
re.IGNORECASE
)
- for s in raw:
- s = s.strip()
- if len(s) < 40:
- continue
- words = s.split()
- if len(words) < 7:
- continue
- # Skip if mostly non-alpha (icons, arrows, encoded chars)
- alpha_chars = sum(1 for c in s if c.isalpha() or c == ' ')
- if alpha_chars < len(s) * 0.6:
- continue
- # Skip nav/menu patterns
- if s.count('|') > 2 or s.count('·') > 2 or s.count('►') > 0:
- continue
- # Skip UI debris
- if noise_patterns.search(s):
- continue
- sentences.append(s)
+
+ def _filter_sentences(raw):
+ result = []
+ for s in raw:
+ s = s.strip()
+ if len(s) < 40 or len(s.split()) < 7:
+ continue
+ alpha_chars = sum(1 for c in s if c.isalpha() or c == ' ')
+ if alpha_chars < len(s) * 0.6:
+ continue
+ if s.count('|') > 2 or s.count('·') > 2 or s.count('►') > 0:
+ continue
+ if noise_patterns.search(s):
+ continue
+ result.append(s)
+ return result
+
+ # Prefer sentences from tags (actual content, not UI)
+ sentences = []
+ if paragraphs:
+ raw = []
+ for p in paragraphs:
+ raw.extend(re.split(r'(?<=[.!?])\s+', p))
+ sentences = _filter_sentences(raw)
+
+ # Fall back to full body text
+ if not sentences:
+ raw = re.split(r'(?<=[.!?])\s+', body)
+ sentences = _filter_sentences(raw)
if not sentences:
- # Last resort: take the first chunk of body that looks like prose
- clean = re.sub(r'\s+', ' ', body).strip()
- return clean[:160] + "..." if len(clean) > 160 else clean
- if len(sentences) == 1:
- s = sentences[0]
- return s[:200] if len(s) > 200 else s
- try:
- from embeddings import embed
- import numpy as np
- embs = embed(sentences[:50]) # cap to avoid embedding too many
- centroid = embs.mean(axis=0, keepdims=True)
- centroid = centroid / max(np.linalg.norm(centroid), 1e-12)
- scores = (embs @ centroid.T).flatten()
- best_idx = int(np.argmax(scores))
- result = sentences[best_idx]
- # Try to add a second sentence if it fits
- if best_idx + 1 < len(sentences) and len(result) + len(sentences[best_idx + 1]) + 1 <= 200:
- result += " " + sentences[best_idx + 1]
- return result[:200] if len(result) > 200 else result
- except Exception:
- return sentences[0][:200]
+ return title[:200] if title else ""
+
+ # Prefer a sentence that mentions the site name
+ if title:
+ title_words = [w.lower() for w in re.split(r'\W+', title) if len(w) >= 3]
+ for s in sentences:
+ s_lower = s.lower()
+ if sum(1 for w in title_words if w in s_lower) >= max(1, len(title_words) // 2):
+ return s[:200]
+
+ # Otherwise use the first quality sentence
+ return sentences[0][:200]
def index_url(url, note=""):
url = clean_url(url)
- title, body, links, meta_desc = fetch_page(url)
- # Use meta description if available, otherwise generate from body
- summary = meta_desc if meta_desc else _generate_summary(title, body)
+ title, body, links, meta_desc, paragraphs = fetch_page(url)
+ # Use meta description if available and meaningful, otherwise generate from body
+ summary = meta_desc if meta_desc and len(meta_desc) > 20 else _generate_summary(title, body, paragraphs)
db = get_db()
try:
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
From 1c2266eaef333a7f38acf296e39016c0250a084b Mon Sep 17 00:00:00 2001
From: blankie
Date: Sat, 28 Mar 2026 20:58:04 -0700
Subject: [PATCH 061/194] made semantic search optional, use meta snippets
- Add semantic_search setting to toggle AI-powered search on/off
- Skip embedding generation, hybrid search, and model preloading when disabled
- Use site owner's meta description as snippet instead of heuristic extraction
- Remove _generate_summary() and snippet() - no more generated snippets
- Show reranker/reindex controls grayed out when semantic search is off
- AI dependencies (onnxruntime, hnswlib, etc.) are now fully optional
---
app.py | 3 ++
db.py | 81 ++++++----------------------------------------
embeddings.py | 7 +---
handlers.py | 89 +++++++++++++++++++++++++++++++++------------------
templates.py | 8 -----
5 files changed, 70 insertions(+), 118 deletions(-)
diff --git a/app.py b/app.py
index 104df32..dba9695 100644
--- a/app.py
+++ b/app.py
@@ -74,6 +74,9 @@ def ensure_rns_config(config_dir):
def _preload_embeddings():
"""Pre-load the embedding model and build the HNSW index in background."""
+ if get_setting("semantic_search", "1") != "1":
+ print("Semantic search disabled.")
+ return
try:
from embeddings import _get_session, _get_reranker, build_index
_get_session() # downloads model on first run, loads ONNX session
diff --git a/db.py b/db.py
index f31473f..065d65d 100644
--- a/db.py
+++ b/db.py
@@ -334,80 +334,16 @@ def fetch_page(url):
tag.decompose()
title = soup.title.string.strip() if soup.title and soup.title.string else url
- # Extract paragraph text for better summary generation
- paragraphs = []
- for p in soup.find_all("p"):
- text = p.get_text(strip=True)
- if len(text) >= 40:
- paragraphs.append(text)
-
body = soup.get_text(separator=" ", strip=True)
- return title, body, links, meta_desc, paragraphs
+ return title, body, links, meta_desc
-def _generate_summary(title, body, paragraphs=None):
- """Generate a summary by extracting the best sentence from the page.
-
- Priority: sentence mentioning the site name > first paragraph sentence
- > first body sentence > title.
- """
- import re
- noise_patterns = re.compile(
- r'arrow-|fedilink|message-square|link-external|'
- r'skip to|cookie|subscribe|sign up|log in|'
- r'privacy policy|terms of|©|\bads?\b',
- re.IGNORECASE
- )
-
- def _filter_sentences(raw):
- result = []
- for s in raw:
- s = s.strip()
- if len(s) < 40 or len(s.split()) < 7:
- continue
- alpha_chars = sum(1 for c in s if c.isalpha() or c == ' ')
- if alpha_chars < len(s) * 0.6:
- continue
- if s.count('|') > 2 or s.count('·') > 2 or s.count('►') > 0:
- continue
- if noise_patterns.search(s):
- continue
- result.append(s)
- return result
-
- # Prefer sentences from tags (actual content, not UI)
- sentences = []
- if paragraphs:
- raw = []
- for p in paragraphs:
- raw.extend(re.split(r'(?<=[.!?])\s+', p))
- sentences = _filter_sentences(raw)
-
- # Fall back to full body text
- if not sentences:
- raw = re.split(r'(?<=[.!?])\s+', body)
- sentences = _filter_sentences(raw)
-
- if not sentences:
- return title[:200] if title else ""
-
- # Prefer a sentence that mentions the site name
- if title:
- title_words = [w.lower() for w in re.split(r'\W+', title) if len(w) >= 3]
- for s in sentences:
- s_lower = s.lower()
- if sum(1 for w in title_words if w in s_lower) >= max(1, len(title_words) // 2):
- return s[:200]
-
- # Otherwise use the first quality sentence
- return sentences[0][:200]
-
def index_url(url, note=""):
url = clean_url(url)
- title, body, links, meta_desc, paragraphs = fetch_page(url)
+ title, body, links, meta_desc = fetch_page(url)
# Use meta description if available and meaningful, otherwise generate from body
- summary = meta_desc if meta_desc and len(meta_desc) > 20 else _generate_summary(title, body, paragraphs)
+ summary = meta_desc if meta_desc and len(meta_desc) > 20 else ""
db = get_db()
try:
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
@@ -425,11 +361,12 @@ def index_url(url, note=""):
(page_id, href, label),
)
db.commit()
- try:
- from embeddings import store_embeddings
- store_embeddings(page_id, title, body, db)
- except Exception:
- pass # embedding generation is best-effort
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import store_embeddings
+ store_embeddings(page_id, title, body, db)
+ except Exception:
+ pass # embedding generation is best-effort
finally:
return_db(db)
return title
diff --git a/embeddings.py b/embeddings.py
index 8ad1362..aa6a4ff 100644
--- a/embeddings.py
+++ b/embeddings.py
@@ -507,7 +507,7 @@ def hybrid_search(query_text, bm25_ranked_ids, limit=10, db=None, use_reranker=F
def reindex_all(db=None, progress_callback=None):
"""Re-embed all pages and regenerate all summaries. Rebuilds HNSW index."""
- from db import get_db, return_db, _generate_summary
+ from db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
@@ -523,11 +523,6 @@ def reindex_all(db=None, progress_callback=None):
total = len(rows)
for i, row in enumerate(rows):
store_embeddings(row["id"], row["title"], row["body"], db)
- # Only regenerate summary if missing
- if not row["summary"]:
- summary = _generate_summary(row["title"], row["body"])
- db.execute("UPDATE pages SET summary = ? WHERE id = ?", (summary, row["id"]))
- db.commit()
if progress_callback:
progress_callback(i + 1, total)
diff --git a/handlers.py b/handlers.py
index 2f6f31f..484a5ca 100644
--- a/handlers.py
+++ b/handlers.py
@@ -6,7 +6,7 @@ from datetime import datetime
from urllib.parse import unquote
from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
-from templates import esc, snippet, wrap_page, DEFAULT_TEMPLATE
+from templates import esc, wrap_page, DEFAULT_TEMPLATE
from rns_client import fetch_remote_sites
_request_local = threading.local()
@@ -205,13 +205,16 @@ def handle_search(query):
# Hybrid search: merge BM25 + semantic via RRF
bm25_ids = [r["id"] for r in bm25_rows]
chunk_snippets = {} # page_id -> best chunk text
- try:
- from embeddings import hybrid_search
- use_reranker = get_setting("use_reranker", "1") == "1"
- fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
- fused_ids = [pid for pid, _ in fused]
- chunk_snippets = {pid: text for pid, text in fused if text}
- except Exception:
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import hybrid_search
+ use_reranker = get_setting("use_reranker", "1") == "1"
+ fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
+ fused_ids = [pid for pid, _ in fused]
+ chunk_snippets = {pid: text for pid, text in fused if text}
+ except Exception:
+ fused_ids = bm25_ids
+ else:
fused_ids = bm25_ids
total_results = len(fused_ids)
@@ -239,13 +242,12 @@ def handle_search(query):
if tags:
tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
tags_html = f'
{tag_links}
'
- # Use page summary as snippet (meta description or centroid sentence)
- snip = r["summary"] if r["summary"] else snippet(r["body"], q)
+ snip_html = f' {esc(r["summary"])}' if r["summary"] else ""
result_html += (
f''
f'
{esc(r["title"])} '
- f'
{esc(r["url"])} '
- f'{esc(snip)}'
+ f'
{esc(r["url"])} '
+ f'{snip_html}'
f'{note_html}{tags_html}'
f'
'
)
@@ -557,8 +559,12 @@ def handle_style_form(msg=""):
name = get_site_name()
sharing = get_setting("sharing_enabled", "0")
checked = " checked" if sharing == "1" else ""
+ semantic = get_setting("semantic_search", "1")
+ semantic_checked = " checked" if semantic == "1" else ""
reranker = get_setting("use_reranker", "1")
reranker_checked = " checked" if reranker == "1" else ""
+ disabled = "" if semantic == "1" else " disabled"
+ dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
return _respond(
f"customize "
f"name your search engine "
@@ -569,9 +575,18 @@ def handle_style_form(msg=""):
f' '
f" share your site list publicly at /api/sites "
f"search "
- f' '
- f" cross-encoder reranking (more accurate, on by default) "
+ f"ai "
+ f' '
+ f" semantic search (similarity matching) "
+ f"Requires onnxruntime, tokenizers, hnswlib. Downloads ~30MB of models on first use. "
+ f'"
f"custom html "
f"Edit the full page template. Use {esc('{{content}}')} "
f"where page content should appear.
"
@@ -596,10 +611,12 @@ def handle_style_submit(body):
template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
name = body.get("site_name", ["tinyweb"])[0].strip()
sharing = "1" if body.get("sharing_enabled") else "0"
+ semantic = "1" if body.get("semantic_search") else "0"
reranker = "1" if body.get("use_reranker") else "0"
set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
set_setting("site_name", name or "tinyweb")
set_setting("sharing_enabled", sharing)
+ set_setting("semantic_search", semantic)
set_setting("use_reranker", reranker)
return handle_style_form("Saved.")
@@ -975,15 +992,16 @@ def handle_subscription_sync(sub_id):
(sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
)
# Embed remote page for semantic search
- try:
- from embeddings import store_remote_embeddings
- rp_id = db.execute(
- "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
- (sub_id, s["url"]),
- ).fetchone()["id"]
- store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
- except Exception:
- pass
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import store_remote_embeddings
+ rp_id = db.execute(
+ "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
+ (sub_id, s["url"]),
+ ).fetchone()["id"]
+ store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
+ except Exception:
+ pass
synced += 1
except Exception:
pass
@@ -1050,15 +1068,16 @@ def handle_subscription_syncall():
"ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
(sub["id"], s["url"], s["title"], s.get("note", ""), tags_str),
)
- try:
- from embeddings import store_remote_embeddings
- rp_id = db.execute(
- "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
- (sub["id"], s["url"]),
- ).fetchone()["id"]
- store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
- except Exception:
- pass
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import store_remote_embeddings
+ rp_id = db.execute(
+ "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
+ (sub["id"], s["url"]),
+ ).fetchone()["id"]
+ store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
+ except Exception:
+ pass
except Exception:
pass
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
@@ -1079,6 +1098,12 @@ _reindex_thread = None
def handle_reindex_form():
+ if get_setting("semantic_search", "1") != "1":
+ return _respond(
+ f"semantic search index "
+ f"Semantic search is disabled. Enable it in settings to use embeddings.
"
+ f'back to search
'
+ )
db = get_db()
try:
total_pages = db.execute("SELECT count(*) FROM pages").fetchone()[0]
diff --git a/templates.py b/templates.py
index 372e736..48beace 100644
--- a/templates.py
+++ b/templates.py
@@ -6,14 +6,6 @@ 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 "")
-
DEFAULT_TEMPLATE = "\n\n\n\n{{content}}\n\n"
From 821e45364b73a9f1fb6e31da681bdc75199c908d Mon Sep 17 00:00:00 2001
From: blankie
Date: Sat, 28 Mar 2026 20:58:04 -0700
Subject: [PATCH 062/194] made semantic search optional, use meta snippets
- Add semantic_search setting to toggle AI-powered search on/off
- Skip embedding generation, hybrid search, and model preloading when disabled
- Use site owner's meta description as snippet instead of heuristic extraction
- Remove _generate_summary() and snippet() - no more generated snippets
- Show reranker/reindex controls grayed out when semantic search is off
- AI dependencies (onnxruntime, hnswlib, etc.) are now fully optional
---
app.py | 3 ++
db.py | 81 ++++++----------------------------------------
embeddings.py | 7 +---
handlers.py | 89 +++++++++++++++++++++++++++++++++------------------
templates.py | 8 -----
5 files changed, 70 insertions(+), 118 deletions(-)
diff --git a/app.py b/app.py
index 104df32..dba9695 100644
--- a/app.py
+++ b/app.py
@@ -74,6 +74,9 @@ def ensure_rns_config(config_dir):
def _preload_embeddings():
"""Pre-load the embedding model and build the HNSW index in background."""
+ if get_setting("semantic_search", "1") != "1":
+ print("Semantic search disabled.")
+ return
try:
from embeddings import _get_session, _get_reranker, build_index
_get_session() # downloads model on first run, loads ONNX session
diff --git a/db.py b/db.py
index f31473f..065d65d 100644
--- a/db.py
+++ b/db.py
@@ -334,80 +334,16 @@ def fetch_page(url):
tag.decompose()
title = soup.title.string.strip() if soup.title and soup.title.string else url
- # Extract paragraph text for better summary generation
- paragraphs = []
- for p in soup.find_all("p"):
- text = p.get_text(strip=True)
- if len(text) >= 40:
- paragraphs.append(text)
-
body = soup.get_text(separator=" ", strip=True)
- return title, body, links, meta_desc, paragraphs
+ return title, body, links, meta_desc
-def _generate_summary(title, body, paragraphs=None):
- """Generate a summary by extracting the best sentence from the page.
-
- Priority: sentence mentioning the site name > first paragraph sentence
- > first body sentence > title.
- """
- import re
- noise_patterns = re.compile(
- r'arrow-|fedilink|message-square|link-external|'
- r'skip to|cookie|subscribe|sign up|log in|'
- r'privacy policy|terms of|©|\bads?\b',
- re.IGNORECASE
- )
-
- def _filter_sentences(raw):
- result = []
- for s in raw:
- s = s.strip()
- if len(s) < 40 or len(s.split()) < 7:
- continue
- alpha_chars = sum(1 for c in s if c.isalpha() or c == ' ')
- if alpha_chars < len(s) * 0.6:
- continue
- if s.count('|') > 2 or s.count('·') > 2 or s.count('►') > 0:
- continue
- if noise_patterns.search(s):
- continue
- result.append(s)
- return result
-
- # Prefer sentences from tags (actual content, not UI)
- sentences = []
- if paragraphs:
- raw = []
- for p in paragraphs:
- raw.extend(re.split(r'(?<=[.!?])\s+', p))
- sentences = _filter_sentences(raw)
-
- # Fall back to full body text
- if not sentences:
- raw = re.split(r'(?<=[.!?])\s+', body)
- sentences = _filter_sentences(raw)
-
- if not sentences:
- return title[:200] if title else ""
-
- # Prefer a sentence that mentions the site name
- if title:
- title_words = [w.lower() for w in re.split(r'\W+', title) if len(w) >= 3]
- for s in sentences:
- s_lower = s.lower()
- if sum(1 for w in title_words if w in s_lower) >= max(1, len(title_words) // 2):
- return s[:200]
-
- # Otherwise use the first quality sentence
- return sentences[0][:200]
-
def index_url(url, note=""):
url = clean_url(url)
- title, body, links, meta_desc, paragraphs = fetch_page(url)
+ title, body, links, meta_desc = fetch_page(url)
# Use meta description if available and meaningful, otherwise generate from body
- summary = meta_desc if meta_desc and len(meta_desc) > 20 else _generate_summary(title, body, paragraphs)
+ summary = meta_desc if meta_desc and len(meta_desc) > 20 else ""
db = get_db()
try:
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
@@ -425,11 +361,12 @@ def index_url(url, note=""):
(page_id, href, label),
)
db.commit()
- try:
- from embeddings import store_embeddings
- store_embeddings(page_id, title, body, db)
- except Exception:
- pass # embedding generation is best-effort
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import store_embeddings
+ store_embeddings(page_id, title, body, db)
+ except Exception:
+ pass # embedding generation is best-effort
finally:
return_db(db)
return title
diff --git a/embeddings.py b/embeddings.py
index 8ad1362..aa6a4ff 100644
--- a/embeddings.py
+++ b/embeddings.py
@@ -507,7 +507,7 @@ def hybrid_search(query_text, bm25_ranked_ids, limit=10, db=None, use_reranker=F
def reindex_all(db=None, progress_callback=None):
"""Re-embed all pages and regenerate all summaries. Rebuilds HNSW index."""
- from db import get_db, return_db, _generate_summary
+ from db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
@@ -523,11 +523,6 @@ def reindex_all(db=None, progress_callback=None):
total = len(rows)
for i, row in enumerate(rows):
store_embeddings(row["id"], row["title"], row["body"], db)
- # Only regenerate summary if missing
- if not row["summary"]:
- summary = _generate_summary(row["title"], row["body"])
- db.execute("UPDATE pages SET summary = ? WHERE id = ?", (summary, row["id"]))
- db.commit()
if progress_callback:
progress_callback(i + 1, total)
diff --git a/handlers.py b/handlers.py
index 2f6f31f..484a5ca 100644
--- a/handlers.py
+++ b/handlers.py
@@ -6,7 +6,7 @@ from datetime import datetime
from urllib.parse import unquote
from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
-from templates import esc, snippet, wrap_page, DEFAULT_TEMPLATE
+from templates import esc, wrap_page, DEFAULT_TEMPLATE
from rns_client import fetch_remote_sites
_request_local = threading.local()
@@ -205,13 +205,16 @@ def handle_search(query):
# Hybrid search: merge BM25 + semantic via RRF
bm25_ids = [r["id"] for r in bm25_rows]
chunk_snippets = {} # page_id -> best chunk text
- try:
- from embeddings import hybrid_search
- use_reranker = get_setting("use_reranker", "1") == "1"
- fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
- fused_ids = [pid for pid, _ in fused]
- chunk_snippets = {pid: text for pid, text in fused if text}
- except Exception:
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import hybrid_search
+ use_reranker = get_setting("use_reranker", "1") == "1"
+ fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
+ fused_ids = [pid for pid, _ in fused]
+ chunk_snippets = {pid: text for pid, text in fused if text}
+ except Exception:
+ fused_ids = bm25_ids
+ else:
fused_ids = bm25_ids
total_results = len(fused_ids)
@@ -239,13 +242,12 @@ def handle_search(query):
if tags:
tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
tags_html = f'
{tag_links}
'
- # Use page summary as snippet (meta description or centroid sentence)
- snip = r["summary"] if r["summary"] else snippet(r["body"], q)
+ snip_html = f' {esc(r["summary"])}' if r["summary"] else ""
result_html += (
f''
f'
{esc(r["title"])} '
- f'
{esc(r["url"])} '
- f'{esc(snip)}'
+ f'
{esc(r["url"])} '
+ f'{snip_html}'
f'{note_html}{tags_html}'
f'
'
)
@@ -557,8 +559,12 @@ def handle_style_form(msg=""):
name = get_site_name()
sharing = get_setting("sharing_enabled", "0")
checked = " checked" if sharing == "1" else ""
+ semantic = get_setting("semantic_search", "1")
+ semantic_checked = " checked" if semantic == "1" else ""
reranker = get_setting("use_reranker", "1")
reranker_checked = " checked" if reranker == "1" else ""
+ disabled = "" if semantic == "1" else " disabled"
+ dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
return _respond(
f"customize "
f"name your search engine "
@@ -569,9 +575,18 @@ def handle_style_form(msg=""):
f' '
f" share your site list publicly at /api/sites "
f"search "
- f' '
- f" cross-encoder reranking (more accurate, on by default) "
+ f"ai "
+ f' '
+ f" semantic search (similarity matching) "
+ f"Requires onnxruntime, tokenizers, hnswlib. Downloads ~30MB of models on first use. "
+ f'"
f"custom html "
f"Edit the full page template. Use {esc('{{content}}')} "
f"where page content should appear.
"
@@ -596,10 +611,12 @@ def handle_style_submit(body):
template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
name = body.get("site_name", ["tinyweb"])[0].strip()
sharing = "1" if body.get("sharing_enabled") else "0"
+ semantic = "1" if body.get("semantic_search") else "0"
reranker = "1" if body.get("use_reranker") else "0"
set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
set_setting("site_name", name or "tinyweb")
set_setting("sharing_enabled", sharing)
+ set_setting("semantic_search", semantic)
set_setting("use_reranker", reranker)
return handle_style_form("Saved.")
@@ -975,15 +992,16 @@ def handle_subscription_sync(sub_id):
(sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
)
# Embed remote page for semantic search
- try:
- from embeddings import store_remote_embeddings
- rp_id = db.execute(
- "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
- (sub_id, s["url"]),
- ).fetchone()["id"]
- store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
- except Exception:
- pass
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import store_remote_embeddings
+ rp_id = db.execute(
+ "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
+ (sub_id, s["url"]),
+ ).fetchone()["id"]
+ store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
+ except Exception:
+ pass
synced += 1
except Exception:
pass
@@ -1050,15 +1068,16 @@ def handle_subscription_syncall():
"ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
(sub["id"], s["url"], s["title"], s.get("note", ""), tags_str),
)
- try:
- from embeddings import store_remote_embeddings
- rp_id = db.execute(
- "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
- (sub["id"], s["url"]),
- ).fetchone()["id"]
- store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
- except Exception:
- pass
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import store_remote_embeddings
+ rp_id = db.execute(
+ "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
+ (sub["id"], s["url"]),
+ ).fetchone()["id"]
+ store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
+ except Exception:
+ pass
except Exception:
pass
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
@@ -1079,6 +1098,12 @@ _reindex_thread = None
def handle_reindex_form():
+ if get_setting("semantic_search", "1") != "1":
+ return _respond(
+ f"semantic search index "
+ f"Semantic search is disabled. Enable it in settings to use embeddings.
"
+ f'back to search
'
+ )
db = get_db()
try:
total_pages = db.execute("SELECT count(*) FROM pages").fetchone()[0]
diff --git a/templates.py b/templates.py
index 372e736..48beace 100644
--- a/templates.py
+++ b/templates.py
@@ -6,14 +6,6 @@ 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 "")
-
DEFAULT_TEMPLATE = "\n\n\n\n{{content}}\n\n"
From b7433c6640f69670a0b65654e17e1659e2d4c4bf Mon Sep 17 00:00:00 2001
From: blankie
Date: Sat, 28 Mar 2026 21:24:10 -0700
Subject: [PATCH 063/194] added manual URL entry
---
db.py | 20 +++++++++++++
handlers.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 99 insertions(+), 2 deletions(-)
diff --git a/db.py b/db.py
index 065d65d..a0a3580 100644
--- a/db.py
+++ b/db.py
@@ -20,6 +20,22 @@ BLOCKED_NETWORKS = [
]
+def _is_blocked_response(html, status_code):
+ """Check if response is a CDN challenge/block page."""
+ if status_code == 403:
+ return True
+ html_lower = html.lower()
+ if "just a moment" in html_lower or "cloudflare" in html_lower:
+ return True
+ if "enable javascript and cookies" in html_lower:
+ return True
+ if "request rejected" in html_lower:
+ return True
+ if "access denied" in html_lower:
+ return True
+ return False
+
+
def _validate_url_target(url):
"""Resolve hostname and block private/internal IPs to prevent SSRF."""
parsed = urlparse(url)
@@ -281,6 +297,10 @@ def get_site_name():
def fetch_page(url):
_validate_url_target(url)
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, allow_redirects=False)
+
+ if _is_blocked_response(resp.text, resp.status_code):
+ raise Exception(f"Site blocks automated access: {resp.status_code}")
+
# Follow redirects manually, re-validating each target
max_redirects = 5
while resp.is_redirect and max_redirects > 0:
diff --git a/handlers.py b/handlers.py
index 484a5ca..5903d0b 100644
--- a/handlers.py
+++ b/handlers.py
@@ -357,10 +357,13 @@ def handle_add_submit(body):
url = clean_url(body.get("url", [""])[0].strip())
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[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 auto-index first
try:
title = index_url(url, note)
if tags:
@@ -373,10 +376,82 @@ def handle_add_submit(body):
finally:
return_db(db)
return handle_add_form(f'Indexed: {esc(title)} ')
+
except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}")
- except Exception:
- return handle_add_form("Error: could not fetch or index that URL.")
+
+ except Exception as e:
+ error_msg = str(e).lower()
+ # Check if it's a block response
+ if "block" in error_msg or "cloudflare" in error_msg or "403" in error_msg:
+ # Show manual entry form for blocked sites
+ return _respond(
+ f"add url (manual entry) "
+ f"{esc(url)} blocks automated access. "
+ f"You can still save it manually:
"
+ f'"
+ f'back '
+ )
+ return handle_add_form(f"Error: could not fetch or index that URL. {esc(str(e)[:100])}")
+
+
+def handle_add_manual_submit(body):
+ url = clean_url(body.get("url", [""])[0].strip())
+ note = body.get("note", [""])[0].strip()
+ tags = body.get("tags", [""])[0].strip()
+ manual_title = body.get("manual_title", [""])[0].strip()
+ manual_desc = body.get("manual_description", [""])[0].strip()
+
+ if not url:
+ return handle_add_form("URL is required.")
+ if not manual_title or not manual_desc:
+ return handle_add_form("Title and description are required for manual entry.")
+
+ db = get_db()
+ try:
+ now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+
+ # Insert the page
+ db.execute(
+ "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
+ (url, manual_title, manual_desc, note, now, manual_desc[:200]),
+ )
+
+ # Get the page ID
+ page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
+
+ # Add tags if provided
+ if tags:
+ _set_page_tags(page_id, tags, db)
+
+ db.commit()
+
+ # Generate embeddings for this page (if semantic search is enabled)
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import store_embeddings
+ # Pass the page_id, title, description, and db connection
+ store_embeddings(page_id, manual_title, manual_desc, db)
+ db.commit()
+ except Exception as e:
+ # Log error but don't fail the whole operation
+ print(f"Error generating embeddings: {e}")
+
+ return handle_add_form(f'Added manually: {esc(manual_title)} ')
+ finally:
+ return_db(db)
def handle_pages(query=None):
@@ -1209,6 +1284,8 @@ def _dispatch_inner(data):
return _respond("403 Forbidden Invalid or missing CSRF token.
", status=403)
if path == "/add":
return handle_add_submit(body)
+ elif path == "/add/manual":
+ return handle_add_manual_submit(body)
elif path.startswith("/edit/"):
pid = extract_id("/edit/")
return handle_edit_submit(pid, body) if pid is not None else _error(400)
From 2aa24b812d1235d8075459fee1952c48ec44c8b8 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sat, 28 Mar 2026 21:24:10 -0700
Subject: [PATCH 064/194] added manual URL entry
---
db.py | 20 +++++++++++++
handlers.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 99 insertions(+), 2 deletions(-)
diff --git a/db.py b/db.py
index 065d65d..a0a3580 100644
--- a/db.py
+++ b/db.py
@@ -20,6 +20,22 @@ BLOCKED_NETWORKS = [
]
+def _is_blocked_response(html, status_code):
+ """Check if response is a CDN challenge/block page."""
+ if status_code == 403:
+ return True
+ html_lower = html.lower()
+ if "just a moment" in html_lower or "cloudflare" in html_lower:
+ return True
+ if "enable javascript and cookies" in html_lower:
+ return True
+ if "request rejected" in html_lower:
+ return True
+ if "access denied" in html_lower:
+ return True
+ return False
+
+
def _validate_url_target(url):
"""Resolve hostname and block private/internal IPs to prevent SSRF."""
parsed = urlparse(url)
@@ -281,6 +297,10 @@ def get_site_name():
def fetch_page(url):
_validate_url_target(url)
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, allow_redirects=False)
+
+ if _is_blocked_response(resp.text, resp.status_code):
+ raise Exception(f"Site blocks automated access: {resp.status_code}")
+
# Follow redirects manually, re-validating each target
max_redirects = 5
while resp.is_redirect and max_redirects > 0:
diff --git a/handlers.py b/handlers.py
index 484a5ca..5903d0b 100644
--- a/handlers.py
+++ b/handlers.py
@@ -357,10 +357,13 @@ def handle_add_submit(body):
url = clean_url(body.get("url", [""])[0].strip())
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[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 auto-index first
try:
title = index_url(url, note)
if tags:
@@ -373,10 +376,82 @@ def handle_add_submit(body):
finally:
return_db(db)
return handle_add_form(f'Indexed: {esc(title)} ')
+
except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}")
- except Exception:
- return handle_add_form("Error: could not fetch or index that URL.")
+
+ except Exception as e:
+ error_msg = str(e).lower()
+ # Check if it's a block response
+ if "block" in error_msg or "cloudflare" in error_msg or "403" in error_msg:
+ # Show manual entry form for blocked sites
+ return _respond(
+ f"add url (manual entry) "
+ f"{esc(url)} blocks automated access. "
+ f"You can still save it manually:
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f' '
+ f' '
+ f'Title: '
+ f' '
+ f'Description: '
+ f' '
+ f'save manually '
+ f" "
+ f'back '
+ )
+ return handle_add_form(f"Error: could not fetch or index that URL. {esc(str(e)[:100])}")
+
+
+def handle_add_manual_submit(body):
+ url = clean_url(body.get("url", [""])[0].strip())
+ note = body.get("note", [""])[0].strip()
+ tags = body.get("tags", [""])[0].strip()
+ manual_title = body.get("manual_title", [""])[0].strip()
+ manual_desc = body.get("manual_description", [""])[0].strip()
+
+ if not url:
+ return handle_add_form("URL is required.")
+ if not manual_title or not manual_desc:
+ return handle_add_form("Title and description are required for manual entry.")
+
+ db = get_db()
+ try:
+ now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+
+ # Insert the page
+ db.execute(
+ "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
+ (url, manual_title, manual_desc, note, now, manual_desc[:200]),
+ )
+
+ # Get the page ID
+ page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
+
+ # Add tags if provided
+ if tags:
+ _set_page_tags(page_id, tags, db)
+
+ db.commit()
+
+ # Generate embeddings for this page (if semantic search is enabled)
+ if get_setting("semantic_search", "1") == "1":
+ try:
+ from embeddings import store_embeddings
+ # Pass the page_id, title, description, and db connection
+ store_embeddings(page_id, manual_title, manual_desc, db)
+ db.commit()
+ except Exception as e:
+ # Log error but don't fail the whole operation
+ print(f"Error generating embeddings: {e}")
+
+ return handle_add_form(f'Added manually: {esc(manual_title)} ')
+ finally:
+ return_db(db)
def handle_pages(query=None):
@@ -1209,6 +1284,8 @@ def _dispatch_inner(data):
return _respond("403 Forbidden Invalid or missing CSRF token.
", status=403)
if path == "/add":
return handle_add_submit(body)
+ elif path == "/add/manual":
+ return handle_add_manual_submit(body)
elif path.startswith("/edit/"):
pid = extract_id("/edit/")
return handle_edit_submit(pid, body) if pid is not None else _error(400)
From b3ff66fba9f44809e3d12c31b16fe0b0a52380f2 Mon Sep 17 00:00:00 2001
From: blankie
Date: Mon, 30 Mar 2026 22:36:58 +0000
Subject: [PATCH 065/194] added reticulum hash option to add page
---
db.py | 17 +++++++++++------
handlers.py | 46 +++++++++++++++++++++++++++++++++-------------
2 files changed, 44 insertions(+), 19 deletions(-)
diff --git a/db.py b/db.py
index a0a3580..4943855 100644
--- a/db.py
+++ b/db.py
@@ -131,7 +131,8 @@ def init_db():
" title TEXT,"
" body TEXT,"
" note TEXT DEFAULT '',"
- " last_modified TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))"
+ " last_modified TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now')),"
+ " reticulum_dest TEXT DEFAULT ''"
")"
)
db.execute(
@@ -247,6 +248,11 @@ def init_db():
db.execute("ALTER TABLE pages ADD COLUMN summary TEXT DEFAULT ''")
db.commit()
+ # Migrate pages: add reticulum_dest column if missing
+ if "reticulum_dest" not in page_cols:
+ db.execute("ALTER TABLE pages ADD COLUMN reticulum_dest TEXT DEFAULT ''")
+ db.commit()
+
# Chunks table for semantic search embeddings
db.execute(
"CREATE TABLE IF NOT EXISTS chunks ("
@@ -359,19 +365,18 @@ def fetch_page(url):
-def index_url(url, note=""):
+def index_url(url, note="", reticulum_dest=""):
url = clean_url(url)
title, body, links, meta_desc = fetch_page(url)
- # Use meta description if available and meaningful, otherwise generate from body
summary = meta_desc if meta_desc and len(meta_desc) > 20 else ""
db = get_db()
try:
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
db.execute(
- "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
+ "INSERT INTO pages (url, title, body, note, last_modified, summary, reticulum_dest) VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
- "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
- (url, title, body, note, now, summary),
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary, reticulum_dest=excluded.reticulum_dest",
+ (url, title, body, note, now, summary, reticulum_dest),
)
page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
diff --git a/handlers.py b/handlers.py
index 5903d0b..a5e66d4 100644
--- a/handlers.py
+++ b/handlers.py
@@ -341,9 +341,12 @@ def handle_search(query):
def handle_add_form(msg=""):
return _respond(
f"add url "
+ f"Add a site via URL or Reticulum destination hash
"
f''
f'{_csrf_field()}'
- f' '
+ f' '
+ f'or '
+ f' '
f' '
f' '
f'index '
@@ -357,15 +360,25 @@ def handle_add_submit(body):
url = clean_url(body.get("url", [""])[0].strip())
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
+ reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "")
- if not url:
- return handle_add_form("URL is required.")
- if not url.startswith(("http://", "https://")):
+ if reticulum_dest and url:
+ return handle_add_form("Please provide either a URL or a Reticulum destination hash, not both.")
+
+ if not url and not reticulum_dest:
+ return handle_add_form("URL or Reticulum destination hash is required.")
+
+ if reticulum_dest and (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.")
+
+ if url and not url.startswith(("http://", "https://")):
return handle_add_form("URL must start with http:// or https://")
- # Try auto-index first
+ if reticulum_dest and not url:
+ url = f"reticulum:{reticulum_dest}"
+
try:
- title = index_url(url, note)
+ title = index_url(url, note, reticulum_dest)
if tags:
db = get_db()
try:
@@ -375,7 +388,9 @@ def handle_add_submit(body):
db.commit()
finally:
return_db(db)
- return handle_add_form(f'Indexed: {esc(title)} ')
+
+ display_url = url if url.startswith("http") else reticulum_dest
+ return handle_add_form(f'Indexed: {esc(display_url)}')
except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}")
@@ -394,6 +409,7 @@ def handle_add_submit(body):
f' '
f' '
f' '
+ f' '
f'Title: '
f' '
f'Description: '
@@ -409,11 +425,16 @@ def handle_add_manual_submit(body):
url = clean_url(body.get("url", [""])[0].strip())
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
+ reticulum_dest = body.get("reticulum_dest", [""])[0].strip()
manual_title = body.get("manual_title", [""])[0].strip()
manual_desc = body.get("manual_description", [""])[0].strip()
- if not url:
- return handle_add_form("URL is required.")
+ if not url and not reticulum_dest:
+ return handle_add_form("URL or Reticulum destination hash is required.")
+
+ if not url and reticulum_dest:
+ url = f"reticulum:{reticulum_dest}"
+
if not manual_title or not manual_desc:
return handle_add_form("Title and description are required for manual entry.")
@@ -421,12 +442,11 @@ def handle_add_manual_submit(body):
try:
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
- # Insert the page
db.execute(
- "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
+ "INSERT INTO pages (url, title, body, note, last_modified, summary, reticulum_dest) VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
- "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
- (url, manual_title, manual_desc, note, now, manual_desc[:200]),
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary, reticulum_dest=excluded.reticulum_dest",
+ (url, manual_title, manual_desc, note, now, manual_desc[:200], reticulum_dest),
)
# Get the page ID
From 63f7d401cd0b32c653d744b781228cd30ed4ccc7 Mon Sep 17 00:00:00 2001
From: blankie
Date: Mon, 30 Mar 2026 22:36:58 +0000
Subject: [PATCH 066/194] added reticulum hash option to add page
---
db.py | 17 +++++++++++------
handlers.py | 46 +++++++++++++++++++++++++++++++++-------------
2 files changed, 44 insertions(+), 19 deletions(-)
diff --git a/db.py b/db.py
index a0a3580..4943855 100644
--- a/db.py
+++ b/db.py
@@ -131,7 +131,8 @@ def init_db():
" title TEXT,"
" body TEXT,"
" note TEXT DEFAULT '',"
- " last_modified TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))"
+ " last_modified TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now')),"
+ " reticulum_dest TEXT DEFAULT ''"
")"
)
db.execute(
@@ -247,6 +248,11 @@ def init_db():
db.execute("ALTER TABLE pages ADD COLUMN summary TEXT DEFAULT ''")
db.commit()
+ # Migrate pages: add reticulum_dest column if missing
+ if "reticulum_dest" not in page_cols:
+ db.execute("ALTER TABLE pages ADD COLUMN reticulum_dest TEXT DEFAULT ''")
+ db.commit()
+
# Chunks table for semantic search embeddings
db.execute(
"CREATE TABLE IF NOT EXISTS chunks ("
@@ -359,19 +365,18 @@ def fetch_page(url):
-def index_url(url, note=""):
+def index_url(url, note="", reticulum_dest=""):
url = clean_url(url)
title, body, links, meta_desc = fetch_page(url)
- # Use meta description if available and meaningful, otherwise generate from body
summary = meta_desc if meta_desc and len(meta_desc) > 20 else ""
db = get_db()
try:
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
db.execute(
- "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
+ "INSERT INTO pages (url, title, body, note, last_modified, summary, reticulum_dest) VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
- "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
- (url, title, body, note, now, summary),
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary, reticulum_dest=excluded.reticulum_dest",
+ (url, title, body, note, now, summary, reticulum_dest),
)
page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
diff --git a/handlers.py b/handlers.py
index 5903d0b..a5e66d4 100644
--- a/handlers.py
+++ b/handlers.py
@@ -341,9 +341,12 @@ def handle_search(query):
def handle_add_form(msg=""):
return _respond(
f"add url "
+ f"Add a site via URL or Reticulum destination hash
"
f''
f'{_csrf_field()}'
- f' '
+ f' '
+ f'or '
+ f' '
f' '
f' '
f'index '
@@ -357,15 +360,25 @@ def handle_add_submit(body):
url = clean_url(body.get("url", [""])[0].strip())
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
+ reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "")
- if not url:
- return handle_add_form("URL is required.")
- if not url.startswith(("http://", "https://")):
+ if reticulum_dest and url:
+ return handle_add_form("Please provide either a URL or a Reticulum destination hash, not both.")
+
+ if not url and not reticulum_dest:
+ return handle_add_form("URL or Reticulum destination hash is required.")
+
+ if reticulum_dest and (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.")
+
+ if url and not url.startswith(("http://", "https://")):
return handle_add_form("URL must start with http:// or https://")
- # Try auto-index first
+ if reticulum_dest and not url:
+ url = f"reticulum:{reticulum_dest}"
+
try:
- title = index_url(url, note)
+ title = index_url(url, note, reticulum_dest)
if tags:
db = get_db()
try:
@@ -375,7 +388,9 @@ def handle_add_submit(body):
db.commit()
finally:
return_db(db)
- return handle_add_form(f'Indexed: {esc(title)} ')
+
+ display_url = url if url.startswith("http") else reticulum_dest
+ return handle_add_form(f'Indexed: {esc(display_url)}')
except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}")
@@ -394,6 +409,7 @@ def handle_add_submit(body):
f' '
f' '
f' '
+ f' '
f'Title: '
f' '
f'Description: '
@@ -409,11 +425,16 @@ def handle_add_manual_submit(body):
url = clean_url(body.get("url", [""])[0].strip())
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
+ reticulum_dest = body.get("reticulum_dest", [""])[0].strip()
manual_title = body.get("manual_title", [""])[0].strip()
manual_desc = body.get("manual_description", [""])[0].strip()
- if not url:
- return handle_add_form("URL is required.")
+ if not url and not reticulum_dest:
+ return handle_add_form("URL or Reticulum destination hash is required.")
+
+ if not url and reticulum_dest:
+ url = f"reticulum:{reticulum_dest}"
+
if not manual_title or not manual_desc:
return handle_add_form("Title and description are required for manual entry.")
@@ -421,12 +442,11 @@ def handle_add_manual_submit(body):
try:
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
- # Insert the page
db.execute(
- "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
+ "INSERT INTO pages (url, title, body, note, last_modified, summary, reticulum_dest) VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
- "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
- (url, manual_title, manual_desc, note, now, manual_desc[:200]),
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary, reticulum_dest=excluded.reticulum_dest",
+ (url, manual_title, manual_desc, note, now, manual_desc[:200], reticulum_dest),
)
# Get the page ID
From def91353f52e363a3dbdf2b27bab8e3797ed5083 Mon Sep 17 00:00:00 2001
From: blankie
Date: Mon, 30 Mar 2026 22:48:45 +0000
Subject: [PATCH 067/194] added dropdown to switch add/subscribe
---
handlers.py | 24 ++++++++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/handlers.py b/handlers.py
index a5e66d4..0d6135b 100644
--- a/handlers.py
+++ b/handlers.py
@@ -338,12 +338,29 @@ def handle_search(query):
)
-def handle_add_form(msg=""):
+def handle_add_form(msg="", action_type="index"):
+ if action_type == "subscribe":
+ return _respond(
+ f"subscribe "
+ f"Subscribe to a friend's TinyWeb instance to sync their index
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f'subscribe '
+ f" "
+ f"or add a single site
"
+ f"{msg}
"
+ f'back '
+ )
return _respond(
f"add url "
f"Add a site via URL or Reticulum destination hash
"
f''
f'{_csrf_field()}'
+ f''
+ f'Add site (index) '
+ f'Subscribe to instance '
+ f' '
f' '
f'or '
f' '
@@ -351,6 +368,7 @@ def handle_add_form(msg=""):
f' '
f'index '
f" "
+ f"or manage subscriptions
"
f"{msg}
"
f'back '
)
@@ -901,6 +919,7 @@ def handle_subscriptions(msg=""):
f' '
f'subscribe '
f' '
+ f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
f'back '
@@ -1266,7 +1285,8 @@ def _dispatch_inner(data):
if path == "/":
return handle_search(query)
elif path == "/add":
- return handle_add_form()
+ action_type = query.get("type", ["index"])[0]
+ return handle_add_form(action_type=action_type if action_type == "subscribe" else "index")
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
From c89e63f88db65d8e9c203f69972e631eedef4dda Mon Sep 17 00:00:00 2001
From: blankie
Date: Mon, 30 Mar 2026 22:48:45 +0000
Subject: [PATCH 068/194] added dropdown to switch add/subscribe
---
handlers.py | 24 ++++++++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/handlers.py b/handlers.py
index a5e66d4..0d6135b 100644
--- a/handlers.py
+++ b/handlers.py
@@ -338,12 +338,29 @@ def handle_search(query):
)
-def handle_add_form(msg=""):
+def handle_add_form(msg="", action_type="index"):
+ if action_type == "subscribe":
+ return _respond(
+ f"subscribe "
+ f"Subscribe to a friend's TinyWeb instance to sync their index
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f'subscribe '
+ f" "
+ f"or add a single site
"
+ f"{msg}
"
+ f'back '
+ )
return _respond(
f"add url "
f"Add a site via URL or Reticulum destination hash
"
f''
f'{_csrf_field()}'
+ f''
+ f'Add site (index) '
+ f'Subscribe to instance '
+ f' '
f' '
f'or '
f' '
@@ -351,6 +368,7 @@ def handle_add_form(msg=""):
f' '
f'index '
f" "
+ f"or manage subscriptions
"
f"{msg}
"
f'back '
)
@@ -901,6 +919,7 @@ def handle_subscriptions(msg=""):
f' '
f'subscribe '
f' '
+ f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
f'back '
@@ -1266,7 +1285,8 @@ def _dispatch_inner(data):
if path == "/":
return handle_search(query)
elif path == "/add":
- return handle_add_form()
+ action_type = query.get("type", ["index"])[0]
+ return handle_add_form(action_type=action_type if action_type == "subscribe" else "index")
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
From 9bdb68043b83be51ed8977f9139662c19d99bab4 Mon Sep 17 00:00:00 2001
From: blankie
Date: Mon, 30 Mar 2026 22:54:29 +0000
Subject: [PATCH 069/194] swapped to radio toggle for URL vs hash
---
handlers.py | 101 +++++++++++++++++++++++++++++++---------------------
1 file changed, 60 insertions(+), 41 deletions(-)
diff --git a/handlers.py b/handlers.py
index 0d6135b..bc39743 100644
--- a/handlers.py
+++ b/handlers.py
@@ -354,49 +354,54 @@ def handle_add_form(msg="", action_type="index"):
)
return _respond(
f"add url "
- f"Add a site via URL or Reticulum destination hash
"
+ f"Add a site to your index
"
f''
f'{_csrf_field()}'
- f''
- f'Add site (index) '
- f'Subscribe to instance '
- f' '
- f' '
- f'or '
- f' '
+ f' '
+ f'URL '
+ f' '
+ f'Reticulum Hash '
+ f' '
+ f' '
+ f' '
f' '
f' '
f'index '
f" "
- f"or manage subscriptions
"
f"{msg}
"
f'back '
+ f''
)
def handle_add_submit(body):
- url = clean_url(body.get("url", [""])[0].strip())
+ input_type = body.get("input_type", ["url"])[0]
+ url = body.get("url", [""])[0].strip()
+ reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "")
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
- reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "")
- if reticulum_dest and url:
- return handle_add_form("Please provide either a URL or a Reticulum destination hash, not both.")
-
- if not url and not reticulum_dest:
- return handle_add_form("URL or Reticulum destination hash is required.")
-
- if reticulum_dest and (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.")
-
- if url and not url.startswith(("http://", "https://")):
- return handle_add_form("URL must start with http:// or https://")
-
- if reticulum_dest and not url:
+ 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}"
try:
- title = index_url(url, note, reticulum_dest)
+ title = index_url(url, note, reticulum_dest if reticulum_dest else "")
if tags:
db = get_db()
try:
@@ -407,8 +412,7 @@ def handle_add_submit(body):
finally:
return_db(db)
- display_url = url if url.startswith("http") else reticulum_dest
- return handle_add_form(f'Indexed: {esc(display_url)}')
+ return handle_add_form(f'Indexed: {esc(url)}')
except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}")
@@ -427,7 +431,6 @@ def handle_add_submit(body):
f' '
f' '
f' '
- f' '
f'Title: '
f' '
f'Description: '
@@ -443,15 +446,11 @@ def handle_add_manual_submit(body):
url = clean_url(body.get("url", [""])[0].strip())
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
- reticulum_dest = body.get("reticulum_dest", [""])[0].strip()
manual_title = body.get("manual_title", [""])[0].strip()
manual_desc = body.get("manual_description", [""])[0].strip()
- if not url and not reticulum_dest:
- return handle_add_form("URL or Reticulum destination hash is required.")
-
- if not url and reticulum_dest:
- url = f"reticulum:{reticulum_dest}"
+ if not url:
+ return handle_add_form("URL is required.")
if not manual_title or not manual_desc:
return handle_add_form("Title and description are required for manual entry.")
@@ -461,10 +460,10 @@ def handle_add_manual_submit(body):
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
db.execute(
- "INSERT INTO pages (url, title, body, note, last_modified, summary, reticulum_dest) VALUES (?, ?, ?, ?, ?, ?, ?) "
+ "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
- "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary, reticulum_dest=excluded.reticulum_dest",
- (url, manual_title, manual_desc, note, now, manual_desc[:200], reticulum_dest),
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
+ (url, manual_title, manual_desc, note, now, manual_desc[:200]),
)
# Get the page ID
@@ -493,6 +492,8 @@ def handle_add_manual_submit(body):
def handle_pages(query=None):
+ msg = query.get("msg", [""])[0] if query else ""
+ msg_html = f'{esc(msg)}
' if msg else ""
page = _paginate(query or {})
offset = (page - 1) * BROWSE_PER_PAGE
db = get_db()
@@ -520,6 +521,7 @@ def handle_pages(query=None):
return_db(db)
return _respond(
f"indexed pages ({total}) "
+ f"{msg_html}"
f""
f'{_page_nav(page, total, "/pages", BROWSE_PER_PAGE)}'
f'export | import
'
@@ -530,20 +532,27 @@ def handle_pages(query=None):
def handle_edit_form(page_id, msg=""):
db = get_db()
try:
- row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone()
+ row = db.execute("SELECT id, url, title, body, note, summary FROM pages WHERE id = ?", (page_id,)).fetchone()
if not row:
return _error(404)
tags = ", ".join(_get_page_tags(page_id, db))
finally:
return_db(db)
+
return _respond(
f"edit page "
f"{esc(row['title'])} "
f"{esc(row['url'])}
"
f''
f'{_csrf_field()}'
- f' '
- f' '
+ f'Title: '
+ f' '
+ f'Summary (shown in search results): '
+ f'{esc(row["summary"] or "")} '
+ f'Note (why you saved this): '
+ f' '
+ f'Tags (comma-separated): '
+ f' '
f'save '
f" "
f"{msg}
"
@@ -552,15 +561,25 @@ def handle_edit_form(page_id, msg=""):
def handle_edit_submit(page_id, body):
+ title = body.get("title", [""])[0].strip()
+ summary = body.get("summary", [""])[0].strip()
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
+
db = get_db()
try:
- db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id))
+ db.execute(
+ "UPDATE pages SET title = ?, summary = ?, note = ? WHERE id = ?",
+ (title, summary, note, page_id)
+ )
+
_set_page_tags(page_id, tags, db)
+
db.commit()
+
finally:
return_db(db)
+
return _redirect("/pages")
From 74c686632de32015f3cb1d1f1ead767c39d0358e Mon Sep 17 00:00:00 2001
From: blankie
Date: Mon, 30 Mar 2026 22:54:29 +0000
Subject: [PATCH 070/194] swapped to radio toggle for URL vs hash
---
handlers.py | 101 +++++++++++++++++++++++++++++++---------------------
1 file changed, 60 insertions(+), 41 deletions(-)
diff --git a/handlers.py b/handlers.py
index 0d6135b..bc39743 100644
--- a/handlers.py
+++ b/handlers.py
@@ -354,49 +354,54 @@ def handle_add_form(msg="", action_type="index"):
)
return _respond(
f"add url "
- f"Add a site via URL or Reticulum destination hash
"
+ f"Add a site to your index
"
f''
f'{_csrf_field()}'
- f''
- f'Add site (index) '
- f'Subscribe to instance '
- f' '
- f' '
- f'or '
- f' '
+ f' '
+ f'URL '
+ f' '
+ f'Reticulum Hash '
+ f' '
+ f' '
+ f' '
f' '
f' '
f'index '
f" "
- f"or manage subscriptions
"
f"{msg}
"
f'back '
+ f''
)
def handle_add_submit(body):
- url = clean_url(body.get("url", [""])[0].strip())
+ input_type = body.get("input_type", ["url"])[0]
+ url = body.get("url", [""])[0].strip()
+ reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "")
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
- reticulum_dest = body.get("reticulum_dest", [""])[0].strip().replace("<", "").replace(">", "")
- if reticulum_dest and url:
- return handle_add_form("Please provide either a URL or a Reticulum destination hash, not both.")
-
- if not url and not reticulum_dest:
- return handle_add_form("URL or Reticulum destination hash is required.")
-
- if reticulum_dest and (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.")
-
- if url and not url.startswith(("http://", "https://")):
- return handle_add_form("URL must start with http:// or https://")
-
- if reticulum_dest and not url:
+ 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}"
try:
- title = index_url(url, note, reticulum_dest)
+ title = index_url(url, note, reticulum_dest if reticulum_dest else "")
if tags:
db = get_db()
try:
@@ -407,8 +412,7 @@ def handle_add_submit(body):
finally:
return_db(db)
- display_url = url if url.startswith("http") else reticulum_dest
- return handle_add_form(f'Indexed: {esc(display_url)}')
+ return handle_add_form(f'Indexed: {esc(url)}')
except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}")
@@ -427,7 +431,6 @@ def handle_add_submit(body):
f' '
f' '
f' '
- f' '
f'Title: '
f' '
f'Description: '
@@ -443,15 +446,11 @@ def handle_add_manual_submit(body):
url = clean_url(body.get("url", [""])[0].strip())
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
- reticulum_dest = body.get("reticulum_dest", [""])[0].strip()
manual_title = body.get("manual_title", [""])[0].strip()
manual_desc = body.get("manual_description", [""])[0].strip()
- if not url and not reticulum_dest:
- return handle_add_form("URL or Reticulum destination hash is required.")
-
- if not url and reticulum_dest:
- url = f"reticulum:{reticulum_dest}"
+ if not url:
+ return handle_add_form("URL is required.")
if not manual_title or not manual_desc:
return handle_add_form("Title and description are required for manual entry.")
@@ -461,10 +460,10 @@ def handle_add_manual_submit(body):
now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
db.execute(
- "INSERT INTO pages (url, title, body, note, last_modified, summary, reticulum_dest) VALUES (?, ?, ?, ?, ?, ?, ?) "
+ "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
- "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary, reticulum_dest=excluded.reticulum_dest",
- (url, manual_title, manual_desc, note, now, manual_desc[:200], reticulum_dest),
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
+ (url, manual_title, manual_desc, note, now, manual_desc[:200]),
)
# Get the page ID
@@ -493,6 +492,8 @@ def handle_add_manual_submit(body):
def handle_pages(query=None):
+ msg = query.get("msg", [""])[0] if query else ""
+ msg_html = f'{esc(msg)}
' if msg else ""
page = _paginate(query or {})
offset = (page - 1) * BROWSE_PER_PAGE
db = get_db()
@@ -520,6 +521,7 @@ def handle_pages(query=None):
return_db(db)
return _respond(
f"indexed pages ({total}) "
+ f"{msg_html}"
f""
f'{_page_nav(page, total, "/pages", BROWSE_PER_PAGE)}'
f'export | import
'
@@ -530,20 +532,27 @@ def handle_pages(query=None):
def handle_edit_form(page_id, msg=""):
db = get_db()
try:
- row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone()
+ row = db.execute("SELECT id, url, title, body, note, summary FROM pages WHERE id = ?", (page_id,)).fetchone()
if not row:
return _error(404)
tags = ", ".join(_get_page_tags(page_id, db))
finally:
return_db(db)
+
return _respond(
f"edit page "
f"{esc(row['title'])} "
f"{esc(row['url'])}
"
f''
f'{_csrf_field()}'
- f' '
- f' '
+ f'Title: '
+ f' '
+ f'Summary (shown in search results): '
+ f'{esc(row["summary"] or "")} '
+ f'Note (why you saved this): '
+ f' '
+ f'Tags (comma-separated): '
+ f' '
f'save '
f" "
f"{msg}
"
@@ -552,15 +561,25 @@ def handle_edit_form(page_id, msg=""):
def handle_edit_submit(page_id, body):
+ title = body.get("title", [""])[0].strip()
+ summary = body.get("summary", [""])[0].strip()
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
+
db = get_db()
try:
- db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id))
+ db.execute(
+ "UPDATE pages SET title = ?, summary = ?, note = ? WHERE id = ?",
+ (title, summary, note, page_id)
+ )
+
_set_page_tags(page_id, tags, db)
+
db.commit()
+
finally:
return_db(db)
+
return _redirect("/pages")
From ebe213f61a9e6904bd0b5550f60ad2f7aceccde5 Mon Sep 17 00:00:00 2001
From: blankie
Date: Mon, 30 Mar 2026 23:01:23 +0000
Subject: [PATCH 071/194] tightened up the add form spacing
---
handlers.py | 15 +--------------
1 file changed, 1 insertion(+), 14 deletions(-)
diff --git a/handlers.py b/handlers.py
index bc39743..1665cae 100644
--- a/handlers.py
+++ b/handlers.py
@@ -357,26 +357,13 @@ def handle_add_form(msg="", action_type="index"):
f"Add a site to your index
"
f''
f'{_csrf_field()}'
- f' '
- f'URL '
- f' '
- f'Reticulum Hash '
- f' '
- f' '
- f' '
+ f' '
f' '
f' '
f'index '
f" "
f"{msg}
"
f'back '
- f''
)
From 6c1a04ad79a99b7163afebe143b66a744dc8e7fb Mon Sep 17 00:00:00 2001
From: blankie
Date: Mon, 30 Mar 2026 23:01:23 +0000
Subject: [PATCH 072/194] tightened up the add form spacing
---
handlers.py | 15 +--------------
1 file changed, 1 insertion(+), 14 deletions(-)
diff --git a/handlers.py b/handlers.py
index bc39743..1665cae 100644
--- a/handlers.py
+++ b/handlers.py
@@ -357,26 +357,13 @@ def handle_add_form(msg="", action_type="index"):
f"Add a site to your index
"
f''
f'{_csrf_field()}'
- f' '
- f'URL '
- f' '
- f'Reticulum Hash '
- f' '
- f' '
- f' '
+ f' '
f' '
f' '
f'index '
f" "
f"{msg}
"
f'back '
- f''
)
From 4e33ca8e885acb9c8c10bde32f09e04112790dca Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 8 Apr 2026 04:36:28 +0000
Subject: [PATCH 073/194] added PyInstaller builds, AGPLv3, transport config
- Add pyinstaller.spec and GitHub/Forgejo CI workflows for cross-platform builds
- Add AGPLv3 license
- Move data storage to ~/.tinyweb/
- Add --version and --port CLI flags
- Add transport node selection in /style (smart regeneration preserves Reticulum config)
- Add discover more nodes link to rmap.world
---
.forgejo/workflows/build.yml | 57 ++++
.github/workflows/build.yml | 75 +++++
LICENSE | 574 +++++++++++++++++++++++++++++++++++
README.md | 19 ++
app.py | 109 ++++++-
db.py | 5 +-
embeddings.py | 8 +-
handlers.py | 16 +-
pyinstaller.spec | 81 +++++
9 files changed, 924 insertions(+), 20 deletions(-)
create mode 100644 .forgejo/workflows/build.yml
create mode 100644 .github/workflows/build.yml
create mode 100644 LICENSE
create mode 100644 pyinstaller.spec
diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml
new file mode 100644
index 0000000..c7c6fa3
--- /dev/null
+++ b/.forgejo/workflows/build.yml
@@ -0,0 +1,57 @@
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: docker
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: |
+ pip install -r requirements.txt
+ pip install pyinstaller
+
+ - name: Build with PyInstaller
+ run: |
+ pyinstaller --onefile --console --name TinyWeb app.py
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: TinyWeb-linux-x64
+ path: dist/TinyWeb
+ if-no-files-found: error
+
+ release:
+ needs: build
+ runs-on: docker
+ if: startsWith(github.ref, 'refs/tags/v')
+
+ steps:
+ - name: Download artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: TinyWeb-linux-x64
+
+ - name: Make executable
+ run: chmod +x TinyWeb-linux-x64
+
+ - name: Create Release
+ uses: actions/forgejo-release@v2
+ with:
+ direction: upload
+ release-dir: .
+ override: true
+ prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') }}
+
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..d79edb3
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,75 @@
+name: Build
+
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ artifact: TinyWeb-windows-x64.exe
+ - os: macos-latest
+ artifact: TinyWeb-macos-arm64
+ - os: ubuntu-latest
+ artifact: TinyWeb-linux-x64
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: |
+ pip install -r requirements.txt
+ pip install pyinstaller
+
+ - name: Build with PyInstaller
+ run: |
+ pyinstaller --onefile --console --name TinyWeb app.py
+
+ - name: Get artifact path
+ id: artifact
+ run: |
+ if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
+ echo "path=dist/TinyWeb.exe" >> $GITHUB_OUTPUT
+ else
+ echo "path=dist/TinyWeb" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create ZIP
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact }}
+ path: ${{ steps.artifact.outputs.path }}
+ if-no-files-found: error
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v')
+
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ files: artifacts/**
+ generate_release_notes: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5445774
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,574 @@
+GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License giving you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+are made publicly available of their being a derivative work, need not
+be distributed to others.
+
+ For example, if you modify a part of a free program, you are
+not required to distribute the object code for the modified version
+itself; however, the GNU Affero General Public License requires you
+to provide source code for any version of the program that you use
+or modify. This requirement is similar to the requirement that
+the user can receive the source code if they distribute a copy.
+
+ Also, if you link or combine the program with any other software
+that contains code covered by this License (or any work based on the
+program), you must provide the source code for that combined work
+as well. The GNU Affero General Public License normally requires
+that any work that you distribute or publish that in whole or in
+part contains or is derived from the program or any part thereof,
+to be licensed as a whole at no charge to all third parties under
+the terms of this License. This is known as "providing source code"
+or "making available" the work.
+
+ An "aggregated" or "combined" work is not covered by this License
+if you do not meet these conditions, and you must provide the source
+code as above. Additionally, aggregating works does not exempt you
+from the requirements of this License.
+
+ Specifically, if you make an "aggregate" or "combined" work by
+combining this program with other software (or any work based on this
+program) on a volume of a storage or distribution medium, you must
+provide the source code for the combined work as above. This
+requirement is intended to ensure that any user of the combined work
+gets the source code that you made available, and can exercise the
+right to modify and re-distribute the combined work.
+
+ This License is specifically intended to limit any attempt to
+place your modifications under a license that would restrict re-use
+or further modification by others. This is to ensure that any
+derivative work you create will be available under the same license
+as the original, so that any derivative work can be re-distributed
+under the same conditions as the original.
+
+ Finally, this License is not intended to limit your rights under
+fair use or other limitations on exclusive rights, such as patents
+or trademarks. This License does not grant you any rights to the
+names of the authors or copyright holders, nor to trade names,
+trademarks, or service marks, except as needed for the normal and
+customary use in describing the origin of the work and reproducing
+the content of the notice file.
+
+ The source code for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided in copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must contain prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must contain prominent notices stating that it is
+ released under this License and any conditions added under section 7.
+ This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the legal rights of the compilation's users beyond
+what the individual works permit. Inclusion of a covered work in an
+aggregate does not cause this License to apply to the other parts of
+the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in
+ accord with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A
+product is a consumer product regardless of whether the product has
+substantial commercial, industrial or non-consumer uses, unless such
+uses represent the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions applicable to the entire Program shall be treated
+as though they were included in this License, to the extent that they
+are valid under applicable law. If additional permissions apply only to
+part of the Program, then that part may be used separately under those
+permissions, but the entire Program remains governed by this License
+without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as
+you received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, or selling the work,
+or by making, using, or selling the work.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available to anyone
+in the United States, you may not convey the work under this License.
+This is done by providing access to copy the corresponding source code
+from a network server at no charge.
+
+ If, during the execution of the Program, the Program is transmitted
+to a user or a computer, either the source code or object code, you
+must meet the requirements of this License regarding the
+Corresponding Source of the work. You must make sure that the source
+code or object code (as applicable) is available for such users to
+copy and modify, and to run, for their own use, the corresponding
+source in accordance with this License. This requirement applies
+both to the work as stand-alone and to the work as part of an
+aggregate.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user
+through a computer network, with no transfer of a copy, is not
+conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 12. No Warranty
+
+ THE PROGRAM IS PROVIDED WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK
+AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD
+THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY
+SERVICING, REPAIR OR CORRECTION.
+
+ 13. Disclaimer of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR
+THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ 14. Interpretation of Sections 12 and 13.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+ The hypothetical commands `show w' and `show c' should show the appropriate
+ parts of the General Public License. Of course, your program's commands
+ might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
+ For more information on this, and how to apply and follow the GNU AGPL,
+ see .
diff --git a/README.md b/README.md
index ec66704..deb1d1e 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,25 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- **Import/export** — JSON-based backup and restore
- **Mesh-native** — Works over Reticulum without the internet; encrypted and decentralized by default
+## Download (pre-built binaries)
+
+Download the latest release for your platform from the [GitHub Releases](https://github.com/anomalyco/tinyweb/releases) page:
+
+| Platform | File |
+|----------|------|
+| Windows | `TinyWeb-windows-x64.exe` |
+| macOS | `TinyWeb-macos-arm64` |
+| Linux | `TinyWeb-linux-x64` |
+
+Run the downloaded file — no installation required. Your data is stored in `~/.tinyweb/`.
+
+### Command line options
+
+```bash
+./TinyWeb --version # Show version
+./TinyWeb -p 9000 # Use port 9000 instead of default 8080
+```
+
## Getting started
```bash
diff --git a/app.py b/app.py
index dba9695..c13c229 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,8 @@
import os
+import sys
import time
import threading
+import argparse
import RNS
from http.server import HTTPServer
@@ -13,18 +15,59 @@ ASPECTS = ["server"]
IDENTITY_FILE = "tinyweb_identity"
DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
DEFAULT_TRANSPORT_PORT = 4242
+DATA_DIR = os.path.expanduser("~/.tinyweb")
+
+
+def get_transport_config():
+ host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))
+ return host, int(port)
+
+
+def find_available_port(start=8080, max_attempts=20):
+ """Find an available port starting from start."""
+ import socket
+ for port in range(start, start + max_attempts):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("0.0.0.0", port))
+ return port
+ except OSError:
+ continue
+ return start
+
+
+def get_version():
+ """Get version from git tag or VERSION file."""
+ try:
+ import subprocess
+ tag = subprocess.check_output(
+ ["git", "describe", "--tags", "--abbrev=0"],
+ stderr=subprocess.DEVNULL,
+ text=True
+ ).strip()
+ if tag.startswith("v"):
+ return tag[1:]
+ return tag
+ except Exception:
+ version_file = os.path.join(os.path.dirname(__file__), "VERSION")
+ if os.path.exists(version_file):
+ with open(version_file) as f:
+ return f.read().strip()
+ return "0.0.0"
def load_or_create_identity():
- if os.path.isfile(IDENTITY_FILE):
- # Ensure identity file is only readable by owner
- current = os.stat(IDENTITY_FILE).st_mode & 0o777
+ os.makedirs(DATA_DIR, exist_ok=True)
+ identity_path = os.path.join(DATA_DIR, IDENTITY_FILE)
+ if os.path.isfile(identity_path):
+ current = os.stat(identity_path).st_mode & 0o777
if current != 0o600:
- os.chmod(IDENTITY_FILE, 0o600)
- return RNS.Identity.from_file(IDENTITY_FILE)
+ os.chmod(identity_path, 0o600)
+ return RNS.Identity.from_file(identity_path)
identity = RNS.Identity()
- identity.to_file(IDENTITY_FILE)
- os.chmod(IDENTITY_FILE, 0o600)
+ identity.to_file(identity_path)
+ os.chmod(identity_path, 0o600)
return identity
@@ -42,13 +85,35 @@ def start_gateway(reticulum):
thread.start()
-def ensure_rns_config(config_dir):
+def _transport_settings_match(config_file, desired_host, desired_port):
+ """Check if existing config transport settings match desired values."""
+ import configparser
+ try:
+ config = configparser.ConfigParser()
+ config.read(config_file)
+ if config.has_section("TCP Transport"):
+ existing_host = config.get("TCP Transport", "target_host")
+ existing_port = config.get("TCP Transport", "target_port")
+ return existing_host == desired_host and existing_port == str(desired_port)
+ except Exception:
+ pass
+ return False
+
+
+def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
"""Generate a default Reticulum config with internet transport if none exists."""
if config_dir is None:
config_dir = os.path.expanduser("~/.reticulum")
config_file = os.path.join(config_dir, "config")
+ if transport_host is None:
+ transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ if transport_port is None:
+ transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
+
if os.path.exists(config_file):
- return
+ if _transport_settings_match(config_file, transport_host, transport_port):
+ return
+
os.makedirs(config_dir, exist_ok=True)
with open(config_file, "w") as f:
f.write(f"""[reticulum]
@@ -66,8 +131,8 @@ def ensure_rns_config(config_dir):
[[TCP Transport]]
type = TCPClientInterface
enabled = yes
- target_host = {DEFAULT_TRANSPORT_HOST}
- target_port = {DEFAULT_TRANSPORT_PORT}
+ target_host = {transport_host}
+ target_port = {transport_port}
""")
print(f"Created Reticulum config at {config_file}")
@@ -79,9 +144,8 @@ def _preload_embeddings():
return
try:
from embeddings import _get_session, _get_reranker, build_index
- _get_session() # downloads model on first run, loads ONNX session
- build_index() # builds HNSW index from existing chunks
- # Preload cross-encoder unless user has explicitly disabled it
+ _get_session()
+ build_index()
if get_setting("use_reranker", "1") == "1":
_get_reranker()
print("Semantic search ready (with reranker).")
@@ -92,10 +156,25 @@ def _preload_embeddings():
def main():
+ parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine")
+ parser.add_argument("--version", "-v", action="store_true", help="Show version")
+ parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)")
+ args = parser.parse_args()
+
+ if args.version:
+ print(f"TinyWeb {get_version()}")
+ return
+
+ port = args.port or 8080
+ import gateway
+ gateway.GATEWAY_PORT = find_available_port(port)
+
init_db()
+ transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
threading.Thread(target=_preload_embeddings, daemon=True).start()
config_dir = os.environ.get("RNS_CONFIG_DIR")
- ensure_rns_config(config_dir)
+ ensure_rns_config(config_dir, transport_host, transport_port)
reticulum = RNS.Reticulum(configdir=config_dir)
identity = load_or_create_identity()
diff --git a/db.py b/db.py
index 4943855..295da86 100644
--- a/db.py
+++ b/db.py
@@ -2,10 +2,12 @@ import socket
import ipaddress
import sqlite3
import requests
+import os
from urllib.parse import urlparse, urljoin, parse_qs, urlencode, urlunparse, quote
from bs4 import BeautifulSoup
-DATABASE = "index.db"
+DATA_DIR = os.path.expanduser("~/.tinyweb")
+DATABASE = os.path.join(DATA_DIR, "index.db")
BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"),
@@ -123,6 +125,7 @@ def return_db(db):
def init_db():
+ os.makedirs(DATA_DIR, exist_ok=True)
db = sqlite3.connect(DATABASE)
db.execute(
"CREATE TABLE IF NOT EXISTS pages ("
diff --git a/embeddings.py b/embeddings.py
index aa6a4ff..302a31f 100644
--- a/embeddings.py
+++ b/embeddings.py
@@ -5,10 +5,11 @@ import re
import threading
import numpy as np
+DATA_DIR = os.path.expanduser("~/.tinyweb")
MODEL_ID = "Snowflake/snowflake-arctic-embed-s"
-MODEL_DIR = os.path.join(os.path.dirname(__file__), "models", "snowflake-arctic-embed-s")
-RERANKER_DIR = os.path.join(os.path.dirname(__file__), "models", "cross-encoder")
-HNSW_PATH = os.path.join(os.path.dirname(__file__), "index.hnsw")
+MODEL_DIR = os.path.join(DATA_DIR, "models", "snowflake-arctic-embed-s")
+RERANKER_DIR = os.path.join(DATA_DIR, "models", "cross-encoder")
+HNSW_PATH = os.path.join(DATA_DIR, "index.hnsw")
DIMS = 384
MAX_TOKENS = 512
QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
@@ -33,6 +34,7 @@ _hnsw_lock = threading.Lock()
def _ensure_model():
"""Download the ONNX model and tokenizer from HuggingFace if not present."""
+ os.makedirs(MODEL_DIR, exist_ok=True)
model_path = os.path.join(MODEL_DIR, "model.onnx")
tokenizer_path = os.path.join(MODEL_DIR, "tokenizer.json")
if os.path.exists(model_path) and os.path.exists(tokenizer_path):
diff --git a/handlers.py b/handlers.py
index 1665cae..a19a62d 100644
--- a/handlers.py
+++ b/handlers.py
@@ -684,6 +684,8 @@ def handle_style_form(msg=""):
reranker_checked = " checked" if reranker == "1" else ""
disabled = "" if semantic == "1" else " disabled"
dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
+ transport_host = get_setting("transport_host", "rnode.bre.land")
+ transport_port = get_setting("transport_port", "4242")
return _respond(
f"customize "
f"name your search engine "
@@ -693,6 +695,12 @@ def handle_style_form(msg=""):
f"sharing "
f' '
f" share your site list publicly at /api/sites "
+ f"mesh network "
+ f"Connect to a Reticulum transport node to reach other peers.
"
+ f"Default: rnode.bre.land:4242 "
+ f' '
+ f' '
+ f'discover more nodes
'
f"search "
f"ai "
f'
Date: Wed, 8 Apr 2026 04:36:28 +0000
Subject: [PATCH 074/194] added PyInstaller builds, AGPLv3, transport config
- Add pyinstaller.spec and GitHub/Forgejo CI workflows for cross-platform builds
- Add AGPLv3 license
- Move data storage to ~/.tinyweb/
- Add --version and --port CLI flags
- Add transport node selection in /style (smart regeneration preserves Reticulum config)
- Add discover more nodes link to rmap.world
---
.forgejo/workflows/build.yml | 57 ++++
.github/workflows/build.yml | 75 +++++
LICENSE | 574 +++++++++++++++++++++++++++++++++++
README.md | 19 ++
app.py | 109 ++++++-
db.py | 5 +-
embeddings.py | 8 +-
handlers.py | 16 +-
pyinstaller.spec | 81 +++++
9 files changed, 924 insertions(+), 20 deletions(-)
create mode 100644 .forgejo/workflows/build.yml
create mode 100644 .github/workflows/build.yml
create mode 100644 LICENSE
create mode 100644 pyinstaller.spec
diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml
new file mode 100644
index 0000000..c7c6fa3
--- /dev/null
+++ b/.forgejo/workflows/build.yml
@@ -0,0 +1,57 @@
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: docker
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: |
+ pip install -r requirements.txt
+ pip install pyinstaller
+
+ - name: Build with PyInstaller
+ run: |
+ pyinstaller --onefile --console --name TinyWeb app.py
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: TinyWeb-linux-x64
+ path: dist/TinyWeb
+ if-no-files-found: error
+
+ release:
+ needs: build
+ runs-on: docker
+ if: startsWith(github.ref, 'refs/tags/v')
+
+ steps:
+ - name: Download artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: TinyWeb-linux-x64
+
+ - name: Make executable
+ run: chmod +x TinyWeb-linux-x64
+
+ - name: Create Release
+ uses: actions/forgejo-release@v2
+ with:
+ direction: upload
+ release-dir: .
+ override: true
+ prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') }}
+
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..d79edb3
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,75 @@
+name: Build
+
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ artifact: TinyWeb-windows-x64.exe
+ - os: macos-latest
+ artifact: TinyWeb-macos-arm64
+ - os: ubuntu-latest
+ artifact: TinyWeb-linux-x64
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: |
+ pip install -r requirements.txt
+ pip install pyinstaller
+
+ - name: Build with PyInstaller
+ run: |
+ pyinstaller --onefile --console --name TinyWeb app.py
+
+ - name: Get artifact path
+ id: artifact
+ run: |
+ if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
+ echo "path=dist/TinyWeb.exe" >> $GITHUB_OUTPUT
+ else
+ echo "path=dist/TinyWeb" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create ZIP
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact }}
+ path: ${{ steps.artifact.outputs.path }}
+ if-no-files-found: error
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v')
+
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ files: artifacts/**
+ generate_release_notes: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5445774
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,574 @@
+GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License giving you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+are made publicly available of their being a derivative work, need not
+be distributed to others.
+
+ For example, if you modify a part of a free program, you are
+not required to distribute the object code for the modified version
+itself; however, the GNU Affero General Public License requires you
+to provide source code for any version of the program that you use
+or modify. This requirement is similar to the requirement that
+the user can receive the source code if they distribute a copy.
+
+ Also, if you link or combine the program with any other software
+that contains code covered by this License (or any work based on the
+program), you must provide the source code for that combined work
+as well. The GNU Affero General Public License normally requires
+that any work that you distribute or publish that in whole or in
+part contains or is derived from the program or any part thereof,
+to be licensed as a whole at no charge to all third parties under
+the terms of this License. This is known as "providing source code"
+or "making available" the work.
+
+ An "aggregated" or "combined" work is not covered by this License
+if you do not meet these conditions, and you must provide the source
+code as above. Additionally, aggregating works does not exempt you
+from the requirements of this License.
+
+ Specifically, if you make an "aggregate" or "combined" work by
+combining this program with other software (or any work based on this
+program) on a volume of a storage or distribution medium, you must
+provide the source code for the combined work as above. This
+requirement is intended to ensure that any user of the combined work
+gets the source code that you made available, and can exercise the
+right to modify and re-distribute the combined work.
+
+ This License is specifically intended to limit any attempt to
+place your modifications under a license that would restrict re-use
+or further modification by others. This is to ensure that any
+derivative work you create will be available under the same license
+as the original, so that any derivative work can be re-distributed
+under the same conditions as the original.
+
+ Finally, this License is not intended to limit your rights under
+fair use or other limitations on exclusive rights, such as patents
+or trademarks. This License does not grant you any rights to the
+names of the authors or copyright holders, nor to trade names,
+trademarks, or service marks, except as needed for the normal and
+customary use in describing the origin of the work and reproducing
+the content of the notice file.
+
+ The source code for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided in copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must contain prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must contain prominent notices stating that it is
+ released under this License and any conditions added under section 7.
+ This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the legal rights of the compilation's users beyond
+what the individual works permit. Inclusion of a covered work in an
+aggregate does not cause this License to apply to the other parts of
+the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in
+ accord with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A
+product is a consumer product regardless of whether the product has
+substantial commercial, industrial or non-consumer uses, unless such
+uses represent the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions applicable to the entire Program shall be treated
+as though they were included in this License, to the extent that they
+are valid under applicable law. If additional permissions apply only to
+part of the Program, then that part may be used separately under those
+permissions, but the entire Program remains governed by this License
+without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as
+you received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, or selling the work,
+or by making, using, or selling the work.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available to anyone
+in the United States, you may not convey the work under this License.
+This is done by providing access to copy the corresponding source code
+from a network server at no charge.
+
+ If, during the execution of the Program, the Program is transmitted
+to a user or a computer, either the source code or object code, you
+must meet the requirements of this License regarding the
+Corresponding Source of the work. You must make sure that the source
+code or object code (as applicable) is available for such users to
+copy and modify, and to run, for their own use, the corresponding
+source in accordance with this License. This requirement applies
+both to the work as stand-alone and to the work as part of an
+aggregate.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user
+through a computer network, with no transfer of a copy, is not
+conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 12. No Warranty
+
+ THE PROGRAM IS PROVIDED WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK
+AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD
+THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY
+SERVICING, REPAIR OR CORRECTION.
+
+ 13. Disclaimer of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR
+THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ 14. Interpretation of Sections 12 and 13.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+ The hypothetical commands `show w' and `show c' should show the appropriate
+ parts of the General Public License. Of course, your program's commands
+ might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
+ For more information on this, and how to apply and follow the GNU AGPL,
+ see .
diff --git a/README.md b/README.md
index ec66704..deb1d1e 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,25 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- **Import/export** — JSON-based backup and restore
- **Mesh-native** — Works over Reticulum without the internet; encrypted and decentralized by default
+## Download (pre-built binaries)
+
+Download the latest release for your platform from the [GitHub Releases](https://github.com/anomalyco/tinyweb/releases) page:
+
+| Platform | File |
+|----------|------|
+| Windows | `TinyWeb-windows-x64.exe` |
+| macOS | `TinyWeb-macos-arm64` |
+| Linux | `TinyWeb-linux-x64` |
+
+Run the downloaded file — no installation required. Your data is stored in `~/.tinyweb/`.
+
+### Command line options
+
+```bash
+./TinyWeb --version # Show version
+./TinyWeb -p 9000 # Use port 9000 instead of default 8080
+```
+
## Getting started
```bash
diff --git a/app.py b/app.py
index dba9695..c13c229 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,8 @@
import os
+import sys
import time
import threading
+import argparse
import RNS
from http.server import HTTPServer
@@ -13,18 +15,59 @@ ASPECTS = ["server"]
IDENTITY_FILE = "tinyweb_identity"
DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
DEFAULT_TRANSPORT_PORT = 4242
+DATA_DIR = os.path.expanduser("~/.tinyweb")
+
+
+def get_transport_config():
+ host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))
+ return host, int(port)
+
+
+def find_available_port(start=8080, max_attempts=20):
+ """Find an available port starting from start."""
+ import socket
+ for port in range(start, start + max_attempts):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("0.0.0.0", port))
+ return port
+ except OSError:
+ continue
+ return start
+
+
+def get_version():
+ """Get version from git tag or VERSION file."""
+ try:
+ import subprocess
+ tag = subprocess.check_output(
+ ["git", "describe", "--tags", "--abbrev=0"],
+ stderr=subprocess.DEVNULL,
+ text=True
+ ).strip()
+ if tag.startswith("v"):
+ return tag[1:]
+ return tag
+ except Exception:
+ version_file = os.path.join(os.path.dirname(__file__), "VERSION")
+ if os.path.exists(version_file):
+ with open(version_file) as f:
+ return f.read().strip()
+ return "0.0.0"
def load_or_create_identity():
- if os.path.isfile(IDENTITY_FILE):
- # Ensure identity file is only readable by owner
- current = os.stat(IDENTITY_FILE).st_mode & 0o777
+ os.makedirs(DATA_DIR, exist_ok=True)
+ identity_path = os.path.join(DATA_DIR, IDENTITY_FILE)
+ if os.path.isfile(identity_path):
+ current = os.stat(identity_path).st_mode & 0o777
if current != 0o600:
- os.chmod(IDENTITY_FILE, 0o600)
- return RNS.Identity.from_file(IDENTITY_FILE)
+ os.chmod(identity_path, 0o600)
+ return RNS.Identity.from_file(identity_path)
identity = RNS.Identity()
- identity.to_file(IDENTITY_FILE)
- os.chmod(IDENTITY_FILE, 0o600)
+ identity.to_file(identity_path)
+ os.chmod(identity_path, 0o600)
return identity
@@ -42,13 +85,35 @@ def start_gateway(reticulum):
thread.start()
-def ensure_rns_config(config_dir):
+def _transport_settings_match(config_file, desired_host, desired_port):
+ """Check if existing config transport settings match desired values."""
+ import configparser
+ try:
+ config = configparser.ConfigParser()
+ config.read(config_file)
+ if config.has_section("TCP Transport"):
+ existing_host = config.get("TCP Transport", "target_host")
+ existing_port = config.get("TCP Transport", "target_port")
+ return existing_host == desired_host and existing_port == str(desired_port)
+ except Exception:
+ pass
+ return False
+
+
+def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
"""Generate a default Reticulum config with internet transport if none exists."""
if config_dir is None:
config_dir = os.path.expanduser("~/.reticulum")
config_file = os.path.join(config_dir, "config")
+ if transport_host is None:
+ transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ if transport_port is None:
+ transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
+
if os.path.exists(config_file):
- return
+ if _transport_settings_match(config_file, transport_host, transport_port):
+ return
+
os.makedirs(config_dir, exist_ok=True)
with open(config_file, "w") as f:
f.write(f"""[reticulum]
@@ -66,8 +131,8 @@ def ensure_rns_config(config_dir):
[[TCP Transport]]
type = TCPClientInterface
enabled = yes
- target_host = {DEFAULT_TRANSPORT_HOST}
- target_port = {DEFAULT_TRANSPORT_PORT}
+ target_host = {transport_host}
+ target_port = {transport_port}
""")
print(f"Created Reticulum config at {config_file}")
@@ -79,9 +144,8 @@ def _preload_embeddings():
return
try:
from embeddings import _get_session, _get_reranker, build_index
- _get_session() # downloads model on first run, loads ONNX session
- build_index() # builds HNSW index from existing chunks
- # Preload cross-encoder unless user has explicitly disabled it
+ _get_session()
+ build_index()
if get_setting("use_reranker", "1") == "1":
_get_reranker()
print("Semantic search ready (with reranker).")
@@ -92,10 +156,25 @@ def _preload_embeddings():
def main():
+ parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine")
+ parser.add_argument("--version", "-v", action="store_true", help="Show version")
+ parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)")
+ args = parser.parse_args()
+
+ if args.version:
+ print(f"TinyWeb {get_version()}")
+ return
+
+ port = args.port or 8080
+ import gateway
+ gateway.GATEWAY_PORT = find_available_port(port)
+
init_db()
+ transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
threading.Thread(target=_preload_embeddings, daemon=True).start()
config_dir = os.environ.get("RNS_CONFIG_DIR")
- ensure_rns_config(config_dir)
+ ensure_rns_config(config_dir, transport_host, transport_port)
reticulum = RNS.Reticulum(configdir=config_dir)
identity = load_or_create_identity()
diff --git a/db.py b/db.py
index 4943855..295da86 100644
--- a/db.py
+++ b/db.py
@@ -2,10 +2,12 @@ import socket
import ipaddress
import sqlite3
import requests
+import os
from urllib.parse import urlparse, urljoin, parse_qs, urlencode, urlunparse, quote
from bs4 import BeautifulSoup
-DATABASE = "index.db"
+DATA_DIR = os.path.expanduser("~/.tinyweb")
+DATABASE = os.path.join(DATA_DIR, "index.db")
BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"),
@@ -123,6 +125,7 @@ def return_db(db):
def init_db():
+ os.makedirs(DATA_DIR, exist_ok=True)
db = sqlite3.connect(DATABASE)
db.execute(
"CREATE TABLE IF NOT EXISTS pages ("
diff --git a/embeddings.py b/embeddings.py
index aa6a4ff..302a31f 100644
--- a/embeddings.py
+++ b/embeddings.py
@@ -5,10 +5,11 @@ import re
import threading
import numpy as np
+DATA_DIR = os.path.expanduser("~/.tinyweb")
MODEL_ID = "Snowflake/snowflake-arctic-embed-s"
-MODEL_DIR = os.path.join(os.path.dirname(__file__), "models", "snowflake-arctic-embed-s")
-RERANKER_DIR = os.path.join(os.path.dirname(__file__), "models", "cross-encoder")
-HNSW_PATH = os.path.join(os.path.dirname(__file__), "index.hnsw")
+MODEL_DIR = os.path.join(DATA_DIR, "models", "snowflake-arctic-embed-s")
+RERANKER_DIR = os.path.join(DATA_DIR, "models", "cross-encoder")
+HNSW_PATH = os.path.join(DATA_DIR, "index.hnsw")
DIMS = 384
MAX_TOKENS = 512
QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
@@ -33,6 +34,7 @@ _hnsw_lock = threading.Lock()
def _ensure_model():
"""Download the ONNX model and tokenizer from HuggingFace if not present."""
+ os.makedirs(MODEL_DIR, exist_ok=True)
model_path = os.path.join(MODEL_DIR, "model.onnx")
tokenizer_path = os.path.join(MODEL_DIR, "tokenizer.json")
if os.path.exists(model_path) and os.path.exists(tokenizer_path):
diff --git a/handlers.py b/handlers.py
index 1665cae..a19a62d 100644
--- a/handlers.py
+++ b/handlers.py
@@ -684,6 +684,8 @@ def handle_style_form(msg=""):
reranker_checked = " checked" if reranker == "1" else ""
disabled = "" if semantic == "1" else " disabled"
dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
+ transport_host = get_setting("transport_host", "rnode.bre.land")
+ transport_port = get_setting("transport_port", "4242")
return _respond(
f"customize "
f"name your search engine "
@@ -693,6 +695,12 @@ def handle_style_form(msg=""):
f"sharing "
f' '
f" share your site list publicly at /api/sites "
+ f"mesh network "
+ f"Connect to a Reticulum transport node to reach other peers.
"
+ f"Default: rnode.bre.land:4242 "
+ f' '
+ f' '
+ f'discover more nodes
'
f"search "
f"ai "
f'
Date: Wed, 8 Apr 2026 05:21:08 +0000
Subject: [PATCH 075/194] disabled semantic search by default
---
README.md | 15 ++++++++++++++-
app.py | 4 ++--
handlers.py | 4 ++--
3 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index deb1d1e..371f15d 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,20 @@ Download the latest release for your platform from the [GitHub Releases](https:/
| macOS | `TinyWeb-macos-arm64` |
| Linux | `TinyWeb-linux-x64` |
-Run the downloaded file — no installation required. Your data is stored in `~/.tinyweb/`.
+Run the downloaded file — no installation required.
+
+## Data storage
+
+Your data is stored in `~/.tinyweb/`:
+
+| File | Description |
+|------|-------------|
+| `index.db` | SQLite database with your indexed pages |
+| `tinyweb_identity` | Your Reticulum identity (keep safe!) |
+| `models/` | Downloaded AI models for semantic search |
+| `index.hnsw` | Semantic search index |
+
+This allows your data to persist between upgrades and stay separate from the application.
### Command line options
diff --git a/app.py b/app.py
index c13c229..da1c56a 100644
--- a/app.py
+++ b/app.py
@@ -139,14 +139,14 @@ def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
def _preload_embeddings():
"""Pre-load the embedding model and build the HNSW index in background."""
- if get_setting("semantic_search", "1") != "1":
+ if get_setting("semantic_search", "0") != "1":
print("Semantic search disabled.")
return
try:
from embeddings import _get_session, _get_reranker, build_index
_get_session()
build_index()
- if get_setting("use_reranker", "1") == "1":
+ if get_setting("use_reranker", "0") == "1":
_get_reranker()
print("Semantic search ready (with reranker).")
else:
diff --git a/handlers.py b/handlers.py
index a19a62d..72bda47 100644
--- a/handlers.py
+++ b/handlers.py
@@ -678,9 +678,9 @@ def handle_style_form(msg=""):
name = get_site_name()
sharing = get_setting("sharing_enabled", "0")
checked = " checked" if sharing == "1" else ""
- semantic = get_setting("semantic_search", "1")
+ semantic = get_setting("semantic_search", "0")
semantic_checked = " checked" if semantic == "1" else ""
- reranker = get_setting("use_reranker", "1")
+ reranker = get_setting("use_reranker", "0")
reranker_checked = " checked" if reranker == "1" else ""
disabled = "" if semantic == "1" else " disabled"
dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
From b32aa7804f44045d37a16111b476d017ecf02eb9 Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 8 Apr 2026 05:21:08 +0000
Subject: [PATCH 076/194] disabled semantic search by default
---
README.md | 15 ++++++++++++++-
app.py | 4 ++--
handlers.py | 4 ++--
3 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index deb1d1e..371f15d 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,20 @@ Download the latest release for your platform from the [GitHub Releases](https:/
| macOS | `TinyWeb-macos-arm64` |
| Linux | `TinyWeb-linux-x64` |
-Run the downloaded file — no installation required. Your data is stored in `~/.tinyweb/`.
+Run the downloaded file — no installation required.
+
+## Data storage
+
+Your data is stored in `~/.tinyweb/`:
+
+| File | Description |
+|------|-------------|
+| `index.db` | SQLite database with your indexed pages |
+| `tinyweb_identity` | Your Reticulum identity (keep safe!) |
+| `models/` | Downloaded AI models for semantic search |
+| `index.hnsw` | Semantic search index |
+
+This allows your data to persist between upgrades and stay separate from the application.
### Command line options
diff --git a/app.py b/app.py
index c13c229..da1c56a 100644
--- a/app.py
+++ b/app.py
@@ -139,14 +139,14 @@ def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
def _preload_embeddings():
"""Pre-load the embedding model and build the HNSW index in background."""
- if get_setting("semantic_search", "1") != "1":
+ if get_setting("semantic_search", "0") != "1":
print("Semantic search disabled.")
return
try:
from embeddings import _get_session, _get_reranker, build_index
_get_session()
build_index()
- if get_setting("use_reranker", "1") == "1":
+ if get_setting("use_reranker", "0") == "1":
_get_reranker()
print("Semantic search ready (with reranker).")
else:
diff --git a/handlers.py b/handlers.py
index a19a62d..72bda47 100644
--- a/handlers.py
+++ b/handlers.py
@@ -678,9 +678,9 @@ def handle_style_form(msg=""):
name = get_site_name()
sharing = get_setting("sharing_enabled", "0")
checked = " checked" if sharing == "1" else ""
- semantic = get_setting("semantic_search", "1")
+ semantic = get_setting("semantic_search", "0")
semantic_checked = " checked" if semantic == "1" else ""
- reranker = get_setting("use_reranker", "1")
+ reranker = get_setting("use_reranker", "0")
reranker_checked = " checked" if reranker == "1" else ""
disabled = "" if semantic == "1" else " disabled"
dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
From b2db40f3d3aeb6685d0c7c206cca99ec692eb6b8 Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 8 Apr 2026 09:05:12 -0700
Subject: [PATCH 077/194] added kodama2 theme
Adds pagination, meta, and success message styles, plus input
selectors for new form fields (edit page, manual entry, transport node).
---
themes/kodama2.html | 1393 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1393 insertions(+)
create mode 100644 themes/kodama2.html
diff --git a/themes/kodama2.html b/themes/kodama2.html
new file mode 100644
index 0000000..9850820
--- /dev/null
+++ b/themes/kodama2.html
@@ -0,0 +1,1393 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ tinyweb
+
+
+
+
+ {{content}}
+
+
+
+
+
+
\ No newline at end of file
From 9fe671e912e9d13ec6c53960aed596aaa1b68654 Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 8 Apr 2026 09:05:12 -0700
Subject: [PATCH 078/194] added kodama2 theme
Adds pagination, meta, and success message styles, plus input
selectors for new form fields (edit page, manual entry, transport node).
---
themes/kodama2.html | 1393 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1393 insertions(+)
create mode 100644 themes/kodama2.html
diff --git a/themes/kodama2.html b/themes/kodama2.html
new file mode 100644
index 0000000..9850820
--- /dev/null
+++ b/themes/kodama2.html
@@ -0,0 +1,1393 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ tinyweb
+
+
+
+
+ {{content}}
+
+
+
+
+
+
\ No newline at end of file
From af3bbc33fd1b1a8ca947f2be53b12fc998d2d527 Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 8 Apr 2026 10:11:57 -0700
Subject: [PATCH 079/194] privacy pass: degoogle, CSP, referrer
- Replace Google Fonts with system font stacks across all themes
- Add Referrer-Policy, X-Content-Type-Options, X-Frame-Options, CSP headers
- Add rel="noreferrer noopener" on all outbound links
- Add no-referrer and dns-prefetch-control meta tags to all themes
- Clean tracking params on outbound links from trusted/remote sources
- Remove Google domains from CSP whitelists
---
gateway.py | 8 +
handlers.py | 19 +-
templates.py | 4 +-
themes/junimo.html | 25 +--
themes/kodama.html | 481 ++++++++++++++++++++++----------------------
themes/kodama2.html | 23 ++-
6 files changed, 285 insertions(+), 275 deletions(-)
diff --git a/gateway.py b/gateway.py
index a13816f..ffafc6a 100644
--- a/gateway.py
+++ b/gateway.py
@@ -123,6 +123,14 @@ class GatewayHandler(BaseHTTPRequestHandler):
self.send_response(resp["status"])
self.send_header("Content-Type", resp.get("content_type", "text/html; charset=utf-8"))
+ self.send_header("Referrer-Policy", "no-referrer")
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.send_header("X-Frame-Options", "DENY")
+ self.send_header("Content-Security-Policy",
+ "default-src 'self'; "
+ "style-src 'self' 'unsafe-inline'; "
+ "script-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data:")
for k, v in resp.get("headers", {}).items():
self.send_header(k, v)
self.end_headers()
diff --git a/handlers.py b/handlers.py
index 72bda47..5048f9c 100644
--- a/handlers.py
+++ b/handlers.py
@@ -245,7 +245,7 @@ def handle_search(query):
snip_html = f' {esc(r["summary"])}' if r["summary"] else ""
result_html += (
f''
- f'
{esc(r["title"])} '
+ f'
{esc(r["title"])} '
f'
{esc(r["url"])} '
f'{snip_html}'
f'{note_html}{tags_html}'
@@ -276,7 +276,7 @@ def handle_search(query):
items = ""
for l in trusted:
items += (
- f'
{esc(l["label"])} '
+ f'{esc(l["label"])} '
f'— from {esc(l["source_title"])} '
)
trusted_html = (
@@ -311,8 +311,8 @@ def handle_search(query):
for r in items:
note_html = f' —
{esc(r["note"])} ' if r["note"] else ""
source_items += (
- f'
{esc(r["title"])} '
- f'{note_html} ({esc(r["url"])}) '
+ f'
{esc(r["title"])} '
+ f'{note_html} ({esc(clean_url(r["url"]))}) '
)
remote_html += (
f'
'
@@ -473,7 +473,7 @@ def handle_add_manual_submit(body):
# Log error but don't fail the whole operation
print(f"Error generating embeddings: {e}")
- return handle_add_form(f'Added manually: {esc(manual_title)} ')
+ return handle_add_form(f'Added manually: {esc(manual_title)} ')
finally:
return_db(db)
@@ -500,7 +500,7 @@ def handle_pages(query=None):
tags_html = f' {tag_links}'
items += (
f'{esc(r["title"])}{note_html}{tags_html} '
- f'({esc(r["url"])} ) '
+ f'({esc(r["url"])} ) '
f'edit '
f'remove '
)
@@ -700,7 +700,7 @@ def handle_style_form(msg=""):
f"Default: rnode.bre.land:4242 "
f' '
f' '
- f'discover more nodes
'
+ f'discover more nodes
'
f"search "
f"ai "
f' [{esc(t)}]' for t in tags)
items += (
f'{esc(r["title"])}{note_html} {tag_links} '
- f'({esc(r["url"])} ) '
+ f'({esc(r["url"])} ) '
)
finally:
return_db(db)
@@ -1397,8 +1397,7 @@ def dispatch_request(data):
resp["headers"]["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
- "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
- "font-src 'self' https://fonts.gstatic.com; "
+ "style-src 'self' 'unsafe-inline'; "
"img-src * data:; "
"frame-ancestors 'none'; "
"form-action 'self'; "
diff --git a/templates.py b/templates.py
index 48beace..0dd9975 100644
--- a/templates.py
+++ b/templates.py
@@ -7,13 +7,13 @@ def esc(s):
-DEFAULT_TEMPLATE = "\n\n\n\n{{content}}\n\n"
+DEFAULT_TEMPLATE = "\n\n \n \n\n\n{{content}}\n\n"
def _default_template():
name = esc(get_setting("site_name", "tinyweb"))
return (
- "\n\n\n\n"
+ '\n\n \n \n\n\n'
f'{name} '
' | search | browse '
' | tags | subscriptions '
diff --git a/themes/junimo.html b/themes/junimo.html
index 25688b7..f85e315 100644
--- a/themes/junimo.html
+++ b/themes/junimo.html
@@ -3,15 +3,16 @@
+
+
+
+
+
+
+
+
From ee20cc32d7a54b1087b996f92686a76fa475c5c4 Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 5 Jun 2026 04:26:22 +0000
Subject: [PATCH 124/194] added tinyweb-site theme
---
themes/kodama2.html | 7 +
themes/tinyweb-site.html | 292 +++++++++++++++++++++++++++++++++++++++
2 files changed, 299 insertions(+)
create mode 100644 themes/tinyweb-site.html
diff --git a/themes/kodama2.html b/themes/kodama2.html
index 641a60e..31bd5fd 100644
--- a/themes/kodama2.html
+++ b/themes/kodama2.html
@@ -143,6 +143,7 @@
input[type="text"],
input[type="url"],
+ input:not([type]),
input[name="q"],
input[name="url"],
input[name="note"],
@@ -328,6 +329,12 @@
label { color: #5a7880; }
input[type="checkbox"] { accent-color: #3a6858; }
+ a.forum-action, a.forum-action-inline {
+ color: #5a7880; border: 1px solid rgba(40, 70, 65, 0.3); border-radius: 4px; padding: 6px 12px;
+ transition: background 0.2s, color 0.2s; font-size: 0.85rem;
+ }
+ a.forum-action:hover, a.forum-action-inline:hover { color: #90b4ac; background: rgba(10, 22, 25, 0.6); }
+ a.forum-action-inline { padding: 2px 6px; border: none; }
hr { border: none; border-top: 1px solid rgba(30, 55, 50, 0.3); margin: 1rem 0; }
small {
diff --git a/themes/tinyweb-site.html b/themes/tinyweb-site.html
new file mode 100644
index 0000000..f4d2f70
--- /dev/null
+++ b/themes/tinyweb-site.html
@@ -0,0 +1,292 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From ccf896200d8e8796c64a9b2fd80f82607e47748e Mon Sep 17 00:00:00 2001
From: blankie
Date: Sat, 6 Jun 2026 01:41:41 +0000
Subject: [PATCH 125/194] move forum layout CSS to main site template system
- Remove FORUM_CSS_DEFAULT/KODAMA2 and _forum_css() from forum handlers
- Add FORUM_CSS constant to templates.py with layout-only forum CSS
- Inject forum CSS into any template's via wrap_page()
- Add forum layout styles to kodama2.html theme
- Update database custom template
---
templates.py | 24 ++++++++++++++++++++++--
themes/kodama2.html | 19 +++++++++++++++++++
2 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/templates.py b/templates.py
index ff533cc..c528b27 100644
--- a/templates.py
+++ b/templates.py
@@ -7,14 +7,30 @@ def esc(s):
return html.escape(str(s))
-DEFAULT_TEMPLATE = "\n\n \n \n\n\n{{content}}\n\n"
+FORUM_CSS = """
+"""
+
+DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{content}}\n\n"
def _default_template():
name = esc(get_setting("site_name", "tinyweb"))
forum_link = ' | forum ' if FORUM_ENABLED else ""
return (
- '\n\n \n \n\n\n'
+ '\n\n \n \n'
+ f'{FORUM_CSS}\n\n'
f'{name} '
' | search | browse '
' | tags | subscriptions '
@@ -34,4 +50,8 @@ def wrap_page(body_html, use_default=False):
forum_link = ' forum ' if FORUM_ENABLED else ""
template = template.replace("{{forum_link}}", forum_link)
template = template.replace("{{site_name}}", esc(get_setting("site_name", "tinyweb")))
+ # Inject forum layout CSS into
for any template
+ head_end = ""
+ if head_end in template and FORUM_CSS not in template:
+ template = template.replace(head_end, FORUM_CSS + head_end)
return template.replace("{{content}}", body_html)
diff --git a/themes/kodama2.html b/themes/kodama2.html
index 31bd5fd..ebdfbfa 100644
--- a/themes/kodama2.html
+++ b/themes/kodama2.html
@@ -335,6 +335,25 @@
}
a.forum-action:hover, a.forum-action-inline:hover { color: #90b4ac; background: rgba(10, 22, 25, 0.6); }
a.forum-action-inline { padding: 2px 6px; border: none; }
+ .forum-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0.5rem 0; }
+ .forum-toolbar form { flex: 1; min-width: 160px; margin: 0; }
+ .forum-toolbar input[name=q] { width: 100%; box-sizing: border-box; padding: 6px 10px; }
+ .forum-toolbar-actions { display: flex; flex-wrap: wrap; gap: 4px; }
+ .forum-status { font-size: 0.82rem; opacity: 0.65; margin: 0 0 0.8rem 0; }
+ .forum-status span { margin-right: 1.2rem; }
+ .forum-nav { margin: 0.5rem 0; }
+ .forum-list { list-style: none; padding: 0; }
+ .forum-list li { padding: 0.6rem 0; }
+ .forum-list li + li { border-top: 1px solid rgba(30, 55, 50, 0.2); }
+ .forum-list .thread-title { font-size: 1.05rem; margin-bottom: 0.1rem; }
+ .forum-list .thread-meta { font-size: 0.8rem; opacity: 0.7; }
+ .forum-list .thread-badge { font-size: 0.78rem; opacity: 0.6; }
+ .post { margin-bottom: 1rem; padding-left: 1rem; border-left: 1px solid rgba(40, 70, 65, 0.3); }
+ .post-meta { font-size: 0.82rem; opacity: 0.7; }
+ .section { margin: 1.5rem 0; }
+ .section-title { font-weight: bold; margin-bottom: 0.3rem; }
+ .section-desc { font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.5rem; }
+ .section ul { margin: 0.3rem 0; }
hr { border: none; border-top: 1px solid rgba(30, 55, 50, 0.3); margin: 1rem 0; }
small {
From 86dbb6ac282c9f055f1688b7340b66d845d54536 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sat, 6 Jun 2026 01:41:41 +0000
Subject: [PATCH 126/194] move forum layout CSS to main site template system
- Remove FORUM_CSS_DEFAULT/KODAMA2 and _forum_css() from forum handlers
- Add FORUM_CSS constant to templates.py with layout-only forum CSS
- Inject forum CSS into any template's via wrap_page()
- Add forum layout styles to kodama2.html theme
- Update database custom template
---
templates.py | 24 ++++++++++++++++++++++--
themes/kodama2.html | 19 +++++++++++++++++++
2 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/templates.py b/templates.py
index ff533cc..c528b27 100644
--- a/templates.py
+++ b/templates.py
@@ -7,14 +7,30 @@ def esc(s):
return html.escape(str(s))
-DEFAULT_TEMPLATE = "\n\n \n \n\n\n{{content}}\n\n"
+FORUM_CSS = """
+"""
+
+DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{content}}\n\n"
def _default_template():
name = esc(get_setting("site_name", "tinyweb"))
forum_link = ' | forum ' if FORUM_ENABLED else ""
return (
- '\n\n \n \n\n\n'
+ '\n\n \n \n'
+ f'{FORUM_CSS}\n\n'
f'{name} '
' | search | browse '
' | tags | subscriptions '
@@ -34,4 +50,8 @@ def wrap_page(body_html, use_default=False):
forum_link = ' forum ' if FORUM_ENABLED else ""
template = template.replace("{{forum_link}}", forum_link)
template = template.replace("{{site_name}}", esc(get_setting("site_name", "tinyweb")))
+ # Inject forum layout CSS into
for any template
+ head_end = ""
+ if head_end in template and FORUM_CSS not in template:
+ template = template.replace(head_end, FORUM_CSS + head_end)
return template.replace("{{content}}", body_html)
diff --git a/themes/kodama2.html b/themes/kodama2.html
index 31bd5fd..ebdfbfa 100644
--- a/themes/kodama2.html
+++ b/themes/kodama2.html
@@ -335,6 +335,25 @@
}
a.forum-action:hover, a.forum-action-inline:hover { color: #90b4ac; background: rgba(10, 22, 25, 0.6); }
a.forum-action-inline { padding: 2px 6px; border: none; }
+ .forum-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0.5rem 0; }
+ .forum-toolbar form { flex: 1; min-width: 160px; margin: 0; }
+ .forum-toolbar input[name=q] { width: 100%; box-sizing: border-box; padding: 6px 10px; }
+ .forum-toolbar-actions { display: flex; flex-wrap: wrap; gap: 4px; }
+ .forum-status { font-size: 0.82rem; opacity: 0.65; margin: 0 0 0.8rem 0; }
+ .forum-status span { margin-right: 1.2rem; }
+ .forum-nav { margin: 0.5rem 0; }
+ .forum-list { list-style: none; padding: 0; }
+ .forum-list li { padding: 0.6rem 0; }
+ .forum-list li + li { border-top: 1px solid rgba(30, 55, 50, 0.2); }
+ .forum-list .thread-title { font-size: 1.05rem; margin-bottom: 0.1rem; }
+ .forum-list .thread-meta { font-size: 0.8rem; opacity: 0.7; }
+ .forum-list .thread-badge { font-size: 0.78rem; opacity: 0.6; }
+ .post { margin-bottom: 1rem; padding-left: 1rem; border-left: 1px solid rgba(40, 70, 65, 0.3); }
+ .post-meta { font-size: 0.82rem; opacity: 0.7; }
+ .section { margin: 1.5rem 0; }
+ .section-title { font-weight: bold; margin-bottom: 0.3rem; }
+ .section-desc { font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.5rem; }
+ .section ul { margin: 0.3rem 0; }
hr { border: none; border-top: 1px solid rgba(30, 55, 50, 0.3); margin: 1rem 0; }
small {
From 207475a4955b6e0969fdb46be4f862bed6ea5d13 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:07:09 +0000
Subject: [PATCH 127/194] =?UTF-8?q?rewrite=20README=20=E2=80=94=20descript?=
=?UTF-8?q?ive=20tone,=20remove=20releases/philosophy=20sections?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 21 +++------------------
1 file changed, 3 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
index 6cbd2f9..116b4f8 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# TinyWeb
-A personal, decentralized search engine built on the [Reticulum](https://reticulum.network/) mesh network. Curate your own index of web pages, search it locally, and share collections with friends over an encrypted mesh. No algorithms, no ads, no tracking.
+A personal, decentralized search engine built on the [Reticulum](https://reticulum.network/) mesh network. You save pages you find. They are stored locally and shared over a mesh network so other people can find them too.
## Features
@@ -37,18 +37,6 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- Paginated at 10,000 pages per request
- Use `?batch=N` to export in chunks: `/export?batch=0`, `/export?batch=1`, etc.
-## Download (pre-built binaries)
-
-Download the latest release for your platform from the [Releases](https://git.derickphan.com/blankie/tinyweb/releases) page:
-
-| Platform | File |
-|----------|------|
-| Windows | `TinyWeb-windows-x64.exe` |
-| macOS | `TinyWeb-macos-arm64` |
-| Linux | `TinyWeb-linux-x64` |
-
-Run the downloaded file — no installation required.
-
## Docker
TinyWeb is distributed as source. Clone the repo, then build and run with Docker Compose:
@@ -144,9 +132,8 @@ docker compose down -v
### Command line options
```bash
-./TinyWeb --version # Show version
-./TinyWeb -p 9000 # Use port 9000 instead of default 8080
-./TinyWeb --bind 0.0.0.0 # Expose the web UI to your LAN (see warning below)
+python app.py -p 9000 # Use port 9000 instead of default 8080
+python app.py --bind 0.0.0.0 # Expose the web UI to your LAN (see warning below)
```
By default, the web UI binds to `127.0.0.1` and is only reachable from the machine running TinyWeb. **The UI has no authentication** — anyone who can reach the port can read, add, and delete entries, and change settings. Only pass `--bind 0.0.0.0` if you fully trust your network, or put TinyWeb behind an authenticating reverse proxy.
@@ -254,6 +241,4 @@ To reduce storage for semantic search embeddings (~50% savings):
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — HTML parsing and link extraction
- [rns](https://reticulum.network/) — Reticulum mesh networking
-## Philosophy
-TinyWeb is built for the slow web — intentionality over speed, human curation over algorithmic feeds, privacy over surveillance, and community over corporations. Every page in your index was saved because you found it valuable, not because an algorithm told you to click.
From fa1f6ccc9e9186f01119e4488a29d1949b9669f9 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:07:09 +0000
Subject: [PATCH 128/194] =?UTF-8?q?rewrite=20README=20=E2=80=94=20descript?=
=?UTF-8?q?ive=20tone,=20remove=20releases/philosophy=20sections?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 21 +++------------------
1 file changed, 3 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
index 6cbd2f9..116b4f8 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# TinyWeb
-A personal, decentralized search engine built on the [Reticulum](https://reticulum.network/) mesh network. Curate your own index of web pages, search it locally, and share collections with friends over an encrypted mesh. No algorithms, no ads, no tracking.
+A personal, decentralized search engine built on the [Reticulum](https://reticulum.network/) mesh network. You save pages you find. They are stored locally and shared over a mesh network so other people can find them too.
## Features
@@ -37,18 +37,6 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- Paginated at 10,000 pages per request
- Use `?batch=N` to export in chunks: `/export?batch=0`, `/export?batch=1`, etc.
-## Download (pre-built binaries)
-
-Download the latest release for your platform from the [Releases](https://git.derickphan.com/blankie/tinyweb/releases) page:
-
-| Platform | File |
-|----------|------|
-| Windows | `TinyWeb-windows-x64.exe` |
-| macOS | `TinyWeb-macos-arm64` |
-| Linux | `TinyWeb-linux-x64` |
-
-Run the downloaded file — no installation required.
-
## Docker
TinyWeb is distributed as source. Clone the repo, then build and run with Docker Compose:
@@ -144,9 +132,8 @@ docker compose down -v
### Command line options
```bash
-./TinyWeb --version # Show version
-./TinyWeb -p 9000 # Use port 9000 instead of default 8080
-./TinyWeb --bind 0.0.0.0 # Expose the web UI to your LAN (see warning below)
+python app.py -p 9000 # Use port 9000 instead of default 8080
+python app.py --bind 0.0.0.0 # Expose the web UI to your LAN (see warning below)
```
By default, the web UI binds to `127.0.0.1` and is only reachable from the machine running TinyWeb. **The UI has no authentication** — anyone who can reach the port can read, add, and delete entries, and change settings. Only pass `--bind 0.0.0.0` if you fully trust your network, or put TinyWeb behind an authenticating reverse proxy.
@@ -254,6 +241,4 @@ To reduce storage for semantic search embeddings (~50% savings):
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — HTML parsing and link extraction
- [rns](https://reticulum.network/) — Reticulum mesh networking
-## Philosophy
-TinyWeb is built for the slow web — intentionality over speed, human curation over algorithmic feeds, privacy over surveillance, and community over corporations. Every page in your index was saved because you found it valuable, not because an algorithm told you to click.
From 88af4d85be257a0d05b796c86aec90eb9c52b9f3 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:07:36 +0000
Subject: [PATCH 129/194] threaded HTTP server, rate limiting, remove slow-web
rhetoric
---
app.py | 4 ++--
gateway.py | 25 +++++++++++++++++++++++--
handlers.py | 19 ++++---------------
templates.py | 12 ++++++++++++
themes/kodama2.html | 3 +++
themes/tinyweb-site.html | 15 +++++++++++++++
6 files changed, 59 insertions(+), 19 deletions(-)
diff --git a/app.py b/app.py
index addbcba..e621322 100644
--- a/app.py
+++ b/app.py
@@ -4,7 +4,7 @@ import time
import threading
import argparse
import RNS
-from http.server import HTTPServer
+from http.server import HTTPServer, ThreadingHTTPServer
from db import init_db, get_setting, set_setting
from handlers import dispatch_request
@@ -101,7 +101,7 @@ def start_gateway(reticulum, bind_host="127.0.0.1"):
GatewayState.reticulum = reticulum
GatewayState.local_dispatch = dispatch_request
HTTPServer.allow_reuse_address = True
- server = HTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
+ server = ThreadingHTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
diff --git a/gateway.py b/gateway.py
index d07924d..7d3fd53 100644
--- a/gateway.py
+++ b/gateway.py
@@ -2,8 +2,9 @@ import re
import sys
import time
import threading
+import collections
import RNS
-from http.server import HTTPServer, BaseHTTPRequestHandler
+from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
APP_NAME = "tinyweb"
@@ -11,6 +12,10 @@ ASPECTS = ["server"]
GATEWAY_PORT = 8080
REQUEST_TIMEOUT = 60
MAX_BODY_SIZE = 16 * 1024 * 1024 # 16 MiB — covers /import and every other form
+RATE_LIMIT_WINDOW = 60
+RATE_LIMIT_MAX = 30
+_rate_tracker = collections.defaultdict(list)
+_rate_lock = threading.Lock()
class GatewayState:
@@ -67,12 +72,28 @@ def ensure_link():
class GatewayHandler(BaseHTTPRequestHandler):
+ def _check_rate_limit(self):
+ client = self.client_address[0]
+ now = time.time()
+ with _rate_lock:
+ times = _rate_tracker[client]
+ cutoff = now - RATE_LIMIT_WINDOW
+ while times and times[0] < cutoff:
+ times.pop(0)
+ if len(times) >= RATE_LIMIT_MAX:
+ return False
+ times.append(now)
+ return True
+
def _forward(self, method):
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
body = {}
if method == "POST":
+ if not self._check_rate_limit():
+ self.send_error(429, "Too many requests — slow down.")
+ return
try:
length = int(self.headers.get("Content-Length", 0))
except ValueError:
@@ -187,7 +208,7 @@ def main():
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()
+ ThreadingHTTPServer(("127.0.0.1", GATEWAY_PORT), GatewayHandler).serve_forever()
if __name__ == "__main__":
diff --git a/handlers.py b/handlers.py
index 1e30735..03b095c 100644
--- a/handlers.py
+++ b/handlers.py
@@ -1043,9 +1043,10 @@ def handle_about():
return _respond(
f'{esc(name)} '
- f'A personal search engine, built for the slow web.
'
- f'TinyWeb is about taking back the internet. No algorithms, no ads, no tracking. '
- f'Just human-curated pages shared freely across a mesh network.
'
+ f'A personal, decentralized search engine.
'
+ f'You save pages you find. They are stored locally and shared over a mesh network '
+ f'so other people can find them too.
'
+ f'Search results come from your index and the indexes of people you are connected to.
'
f''
f'{page_count} page(s) indexed '
f'{tag_count} tag(s) '
@@ -1069,18 +1070,6 @@ def handle_about():
f'The export page gives you a JSON dump of pages only — '
f'it does not preserve your identity or subscription state, so it is a migration aid, '
f'not a substitute for a full backup.'
- f'what is the slow web? '
- f'The slow web is a movement for intentionality over speed, '
- f'human curation over algorithmic feeds, privacy over surveillance, '
- f'and community over corporations. Every page in this index was saved by a person '
- f'because they found it valuable — not because an algorithm told them to click.
'
- f'how it works '
- f''
- f'Save pages you find valuable with the bookmarklet or /add '
- f'Search your personal index — queries never leave your machine '
- f'Subscribe to friends over Reticulum — encrypted, decentralized, works without the internet '
- f'Tag and organize your collection into curated lists '
- f' '
f'search | browse | tags
'
)
diff --git a/templates.py b/templates.py
index c528b27..777ed63 100644
--- a/templates.py
+++ b/templates.py
@@ -12,6 +12,14 @@ FORUM_CSS = """
.forum-form { max-width: 500px; }
.forum-form input:not([type=checkbox]):not([type=radio]), .forum-form textarea { width: 100%; box-sizing: border-box; }
.forum-form textarea { resize: vertical; }
+.forum-form input, .forum-form textarea, .forum-form button { margin-bottom: 8px; }
+.forum-form small { display: block; margin-bottom: 6px; }
+.forum-form label { display: block; margin-bottom: 6px; }
+.forum-form + .forum-form { margin-top: 1rem; }
+.forum-form + .section-title { margin-top: 1rem; }
+.section-desc + .forum-form { margin-top: 0.8rem; }
+ul + .forum-form { margin-top: 1rem; }
+.checkbox-label { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.forum-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.forum-toolbar form { flex: 1; min-width: 160px; margin: 0; }
.forum-toolbar input[name=q] { width: 100%; box-sizing: border-box; }
@@ -20,6 +28,10 @@ FORUM_CSS = """
.forum-status { margin: 0 0 0.8rem 0; }
.forum-status span { margin-right: 0.5rem; }
.forum-nav { margin: 0.5rem 0; }
+.section { margin: 1.5rem 0; }
+.section-title { font-weight: 600; margin-bottom: 0.3rem; }
+.section-desc { font-size: 0.85rem; margin-bottom: 0.5rem; }
+.section ul { margin: 0.3rem 0; }
"""
DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{content}}\n\n"
diff --git a/themes/kodama2.html b/themes/kodama2.html
index ebdfbfa..34fc125 100644
--- a/themes/kodama2.html
+++ b/themes/kodama2.html
@@ -348,6 +348,9 @@
.forum-list .thread-title { font-size: 1.05rem; margin-bottom: 0.1rem; }
.forum-list .thread-meta { font-size: 0.8rem; opacity: 0.7; }
.forum-list .thread-badge { font-size: 0.78rem; opacity: 0.6; }
+ .forum-form input, .forum-form textarea, .forum-form button { margin-bottom: 8px; }
+ .forum-form small { display: block; margin-bottom: 6px; }
+ .forum-form label { display: block; margin-bottom: 6px; }
.post { margin-bottom: 1rem; padding-left: 1rem; border-left: 1px solid rgba(40, 70, 65, 0.3); }
.post-meta { font-size: 0.82rem; opacity: 0.7; }
.section { margin: 1.5rem 0; }
diff --git a/themes/tinyweb-site.html b/themes/tinyweb-site.html
index f4d2f70..bda39ec 100644
--- a/themes/tinyweb-site.html
+++ b/themes/tinyweb-site.html
@@ -223,6 +223,21 @@
background: #f5f5f5;
}
a.forum-action-inline { text-transform: none; font-size: 13px; padding: 2px 6px; border: none; }
+ .section { margin: 1.5rem 0; }
+ .section-title { font-weight: 600; margin-bottom: 0.3rem; }
+ .section-desc { font-size: 0.85rem; color: #999; margin-bottom: 0.5rem; }
+ .section ul { margin: 0.3rem 0; }
+ .forum-form input, .forum-form textarea, .forum-form button { margin-bottom: 8px; }
+ .forum-form small { display: block; margin-bottom: 6px; }
+ .forum-form label { display: block; margin-bottom: 6px; }
+ .forum-form + .forum-form { margin-top: 1rem; }
+ .forum-form + .section-title { margin-top: 1rem; }
+ .section-desc + .forum-form { margin-top: 0.8rem; }
+ ul + .forum-form { margin-top: 1rem; }
+ .checkbox-label { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
+ .forum-status { font-size: 0.82rem; color: #999; margin: 0 0 0.8rem 0; }
+ .forum-status span { margin-right: 1.2rem; }
+ .forum-nav { margin: 1rem 0; }
hr { border: none; border-top: 1px solid #eee; margin: 16px 0; }
small {
From dc484f1d600111bc927c8299be06f557a811f451 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:07:36 +0000
Subject: [PATCH 130/194] threaded HTTP server, rate limiting, remove slow-web
rhetoric
---
app.py | 4 ++--
gateway.py | 25 +++++++++++++++++++++++--
handlers.py | 19 ++++---------------
templates.py | 12 ++++++++++++
themes/kodama2.html | 3 +++
themes/tinyweb-site.html | 15 +++++++++++++++
6 files changed, 59 insertions(+), 19 deletions(-)
diff --git a/app.py b/app.py
index addbcba..e621322 100644
--- a/app.py
+++ b/app.py
@@ -4,7 +4,7 @@ import time
import threading
import argparse
import RNS
-from http.server import HTTPServer
+from http.server import HTTPServer, ThreadingHTTPServer
from db import init_db, get_setting, set_setting
from handlers import dispatch_request
@@ -101,7 +101,7 @@ def start_gateway(reticulum, bind_host="127.0.0.1"):
GatewayState.reticulum = reticulum
GatewayState.local_dispatch = dispatch_request
HTTPServer.allow_reuse_address = True
- server = HTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
+ server = ThreadingHTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
diff --git a/gateway.py b/gateway.py
index d07924d..7d3fd53 100644
--- a/gateway.py
+++ b/gateway.py
@@ -2,8 +2,9 @@ import re
import sys
import time
import threading
+import collections
import RNS
-from http.server import HTTPServer, BaseHTTPRequestHandler
+from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
APP_NAME = "tinyweb"
@@ -11,6 +12,10 @@ ASPECTS = ["server"]
GATEWAY_PORT = 8080
REQUEST_TIMEOUT = 60
MAX_BODY_SIZE = 16 * 1024 * 1024 # 16 MiB — covers /import and every other form
+RATE_LIMIT_WINDOW = 60
+RATE_LIMIT_MAX = 30
+_rate_tracker = collections.defaultdict(list)
+_rate_lock = threading.Lock()
class GatewayState:
@@ -67,12 +72,28 @@ def ensure_link():
class GatewayHandler(BaseHTTPRequestHandler):
+ def _check_rate_limit(self):
+ client = self.client_address[0]
+ now = time.time()
+ with _rate_lock:
+ times = _rate_tracker[client]
+ cutoff = now - RATE_LIMIT_WINDOW
+ while times and times[0] < cutoff:
+ times.pop(0)
+ if len(times) >= RATE_LIMIT_MAX:
+ return False
+ times.append(now)
+ return True
+
def _forward(self, method):
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
body = {}
if method == "POST":
+ if not self._check_rate_limit():
+ self.send_error(429, "Too many requests — slow down.")
+ return
try:
length = int(self.headers.get("Content-Length", 0))
except ValueError:
@@ -187,7 +208,7 @@ def main():
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()
+ ThreadingHTTPServer(("127.0.0.1", GATEWAY_PORT), GatewayHandler).serve_forever()
if __name__ == "__main__":
diff --git a/handlers.py b/handlers.py
index 1e30735..03b095c 100644
--- a/handlers.py
+++ b/handlers.py
@@ -1043,9 +1043,10 @@ def handle_about():
return _respond(
f'{esc(name)} '
- f'A personal search engine, built for the slow web.
'
- f'TinyWeb is about taking back the internet. No algorithms, no ads, no tracking. '
- f'Just human-curated pages shared freely across a mesh network.
'
+ f'A personal, decentralized search engine.
'
+ f'You save pages you find. They are stored locally and shared over a mesh network '
+ f'so other people can find them too.
'
+ f'Search results come from your index and the indexes of people you are connected to.
'
f''
f'{page_count} page(s) indexed '
f'{tag_count} tag(s) '
@@ -1069,18 +1070,6 @@ def handle_about():
f'The export page gives you a JSON dump of pages only — '
f'it does not preserve your identity or subscription state, so it is a migration aid, '
f'not a substitute for a full backup.'
- f'what is the slow web? '
- f'The slow web is a movement for intentionality over speed, '
- f'human curation over algorithmic feeds, privacy over surveillance, '
- f'and community over corporations. Every page in this index was saved by a person '
- f'because they found it valuable — not because an algorithm told them to click.
'
- f'how it works '
- f''
- f'Save pages you find valuable with the bookmarklet or /add '
- f'Search your personal index — queries never leave your machine '
- f'Subscribe to friends over Reticulum — encrypted, decentralized, works without the internet '
- f'Tag and organize your collection into curated lists '
- f' '
f'search | browse | tags
'
)
diff --git a/templates.py b/templates.py
index c528b27..777ed63 100644
--- a/templates.py
+++ b/templates.py
@@ -12,6 +12,14 @@ FORUM_CSS = """
.forum-form { max-width: 500px; }
.forum-form input:not([type=checkbox]):not([type=radio]), .forum-form textarea { width: 100%; box-sizing: border-box; }
.forum-form textarea { resize: vertical; }
+.forum-form input, .forum-form textarea, .forum-form button { margin-bottom: 8px; }
+.forum-form small { display: block; margin-bottom: 6px; }
+.forum-form label { display: block; margin-bottom: 6px; }
+.forum-form + .forum-form { margin-top: 1rem; }
+.forum-form + .section-title { margin-top: 1rem; }
+.section-desc + .forum-form { margin-top: 0.8rem; }
+ul + .forum-form { margin-top: 1rem; }
+.checkbox-label { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.forum-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.forum-toolbar form { flex: 1; min-width: 160px; margin: 0; }
.forum-toolbar input[name=q] { width: 100%; box-sizing: border-box; }
@@ -20,6 +28,10 @@ FORUM_CSS = """
.forum-status { margin: 0 0 0.8rem 0; }
.forum-status span { margin-right: 0.5rem; }
.forum-nav { margin: 0.5rem 0; }
+.section { margin: 1.5rem 0; }
+.section-title { font-weight: 600; margin-bottom: 0.3rem; }
+.section-desc { font-size: 0.85rem; margin-bottom: 0.5rem; }
+.section ul { margin: 0.3rem 0; }
"""
DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{content}}\n\n"
diff --git a/themes/kodama2.html b/themes/kodama2.html
index ebdfbfa..34fc125 100644
--- a/themes/kodama2.html
+++ b/themes/kodama2.html
@@ -348,6 +348,9 @@
.forum-list .thread-title { font-size: 1.05rem; margin-bottom: 0.1rem; }
.forum-list .thread-meta { font-size: 0.8rem; opacity: 0.7; }
.forum-list .thread-badge { font-size: 0.78rem; opacity: 0.6; }
+ .forum-form input, .forum-form textarea, .forum-form button { margin-bottom: 8px; }
+ .forum-form small { display: block; margin-bottom: 6px; }
+ .forum-form label { display: block; margin-bottom: 6px; }
.post { margin-bottom: 1rem; padding-left: 1rem; border-left: 1px solid rgba(40, 70, 65, 0.3); }
.post-meta { font-size: 0.82rem; opacity: 0.7; }
.section { margin: 1.5rem 0; }
diff --git a/themes/tinyweb-site.html b/themes/tinyweb-site.html
index f4d2f70..bda39ec 100644
--- a/themes/tinyweb-site.html
+++ b/themes/tinyweb-site.html
@@ -223,6 +223,21 @@
background: #f5f5f5;
}
a.forum-action-inline { text-transform: none; font-size: 13px; padding: 2px 6px; border: none; }
+ .section { margin: 1.5rem 0; }
+ .section-title { font-weight: 600; margin-bottom: 0.3rem; }
+ .section-desc { font-size: 0.85rem; color: #999; margin-bottom: 0.5rem; }
+ .section ul { margin: 0.3rem 0; }
+ .forum-form input, .forum-form textarea, .forum-form button { margin-bottom: 8px; }
+ .forum-form small { display: block; margin-bottom: 6px; }
+ .forum-form label { display: block; margin-bottom: 6px; }
+ .forum-form + .forum-form { margin-top: 1rem; }
+ .forum-form + .section-title { margin-top: 1rem; }
+ .section-desc + .forum-form { margin-top: 0.8rem; }
+ ul + .forum-form { margin-top: 1rem; }
+ .checkbox-label { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
+ .forum-status { font-size: 0.82rem; color: #999; margin: 0 0 0.8rem 0; }
+ .forum-status span { margin-right: 1.2rem; }
+ .forum-nav { margin: 1rem 0; }
hr { border: none; border-top: 1px solid #eee; margin: 16px 0; }
small {
From 8504aa0d7276b46c344b1612fc0c9de27513155f Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:08:39 +0000
Subject: [PATCH 131/194] remove forgejo CI workflow (no releases)
---
.forgejo/workflows/build.yml | 81 ------------------------------------
1 file changed, 81 deletions(-)
delete mode 100644 .forgejo/workflows/build.yml
diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml
deleted file mode 100644
index a145b1d..0000000
--- a/.forgejo/workflows/build.yml
+++ /dev/null
@@ -1,81 +0,0 @@
-on:
- push:
- tags:
- - 'v*.*.*'
- workflow_dispatch:
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: https://code.forgejo.org/actions/checkout@v4
-
- - name: Set up Python
- run: |
- apt-get update && apt-get install -y python3 python3-pip python3-venv jq curl
- curl -fsSL https://get.docker.com | sh
- pip3 install --break-system-packages -r requirements.txt
- pip3 install --break-system-packages pyinstaller
-
- - name: Build with PyInstaller
- run: |
- pyinstaller --onefile --console --name TinyWeb app.py
-
- - name: Prepare artifact
- run: |
- cp dist/TinyWeb TinyWeb-linux-x64
- chmod +x TinyWeb-linux-x64
- ls -la TinyWeb-linux-x64
-
- - name: Get Release ID
- if: startsWith(github.ref, 'refs/tags/v')
- id: release
- run: |
- TAG="${{ github.ref_name }}"
- REPO="${{ github.repository }}"
- TOKEN="${{ secrets.FORGEJO_TOKEN }}"
- RELEASE_JSON=$(curl -s "https://git.example.com/api/v1/repos/$REPO/releases/tags/$TAG" \
- -H "Authorization: token $TOKEN")
- echo "$RELEASE_JSON"
- RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
- echo "release_id=$RELEASE_ID" >> $FORGEJO_OUTPUT
-
- - name: Upload to Release
- if: startsWith(github.ref, 'refs/tags/v')
- run: |
- FILE=TinyWeb-linux-x64
- RELEASE_ID="${{ steps.release.outputs.release_id }}"
- REPO="${{ github.repository }}"
- TOKEN="${{ secrets.FORGEJO_TOKEN }}"
- curl -X POST "https://git.example.com/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" \
- -H "Authorization: token $TOKEN" \
- -F "attachment=@$FILE"
-
- - name: Login to Registry
- run: |
- echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.example.com -u _ --password-stdin
-
- - name: Build and push Docker image
- run: |
- TAG="${{ github.ref_name }}"
- if [ -z "$TAG" ]; then
- TAG="latest"
- fi
- # Configure Docker daemon with DNS
- mkdir -p ~/.docker
- cat > ~/.docker/daemon.json << 'EOF'
- {
- "dns": ["8.8.8.8", "1.1.1.1"],
- "builder": {
- "features": {
- "buildkit": true
- }
- }
- }
- EOF
- # Build with buildkit
- DOCKER_BUILDKIT=1 docker build --network=host -t registry.example.com/tinyweb:$TAG .
- docker push registry.example.com/tinyweb:$TAG
-
From 8ccd8a5fd38dc71662f03a1adc90943e28475975 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:08:39 +0000
Subject: [PATCH 132/194] remove forgejo CI workflow (no releases)
---
.forgejo/workflows/build.yml | 81 ------------------------------------
1 file changed, 81 deletions(-)
delete mode 100644 .forgejo/workflows/build.yml
diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml
deleted file mode 100644
index a145b1d..0000000
--- a/.forgejo/workflows/build.yml
+++ /dev/null
@@ -1,81 +0,0 @@
-on:
- push:
- tags:
- - 'v*.*.*'
- workflow_dispatch:
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: https://code.forgejo.org/actions/checkout@v4
-
- - name: Set up Python
- run: |
- apt-get update && apt-get install -y python3 python3-pip python3-venv jq curl
- curl -fsSL https://get.docker.com | sh
- pip3 install --break-system-packages -r requirements.txt
- pip3 install --break-system-packages pyinstaller
-
- - name: Build with PyInstaller
- run: |
- pyinstaller --onefile --console --name TinyWeb app.py
-
- - name: Prepare artifact
- run: |
- cp dist/TinyWeb TinyWeb-linux-x64
- chmod +x TinyWeb-linux-x64
- ls -la TinyWeb-linux-x64
-
- - name: Get Release ID
- if: startsWith(github.ref, 'refs/tags/v')
- id: release
- run: |
- TAG="${{ github.ref_name }}"
- REPO="${{ github.repository }}"
- TOKEN="${{ secrets.FORGEJO_TOKEN }}"
- RELEASE_JSON=$(curl -s "https://git.example.com/api/v1/repos/$REPO/releases/tags/$TAG" \
- -H "Authorization: token $TOKEN")
- echo "$RELEASE_JSON"
- RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
- echo "release_id=$RELEASE_ID" >> $FORGEJO_OUTPUT
-
- - name: Upload to Release
- if: startsWith(github.ref, 'refs/tags/v')
- run: |
- FILE=TinyWeb-linux-x64
- RELEASE_ID="${{ steps.release.outputs.release_id }}"
- REPO="${{ github.repository }}"
- TOKEN="${{ secrets.FORGEJO_TOKEN }}"
- curl -X POST "https://git.example.com/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" \
- -H "Authorization: token $TOKEN" \
- -F "attachment=@$FILE"
-
- - name: Login to Registry
- run: |
- echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.example.com -u _ --password-stdin
-
- - name: Build and push Docker image
- run: |
- TAG="${{ github.ref_name }}"
- if [ -z "$TAG" ]; then
- TAG="latest"
- fi
- # Configure Docker daemon with DNS
- mkdir -p ~/.docker
- cat > ~/.docker/daemon.json << 'EOF'
- {
- "dns": ["8.8.8.8", "1.1.1.1"],
- "builder": {
- "features": {
- "buildkit": true
- }
- }
- }
- EOF
- # Build with buildkit
- DOCKER_BUILDKIT=1 docker build --network=host -t registry.example.com/tinyweb:$TAG .
- docker push registry.example.com/tinyweb:$TAG
-
From 1aaa88b06a37e82fba1eb29aed3f99bc0c83cc94 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:16:54 +0000
Subject: [PATCH 133/194] clean up: remove unused start.sh/pyinstaller.spec,
deduplicate constants, update .gitignore
---
.gitignore | 5 +++
app.py | 6 ++--
pyinstaller.spec | 81 ------------------------------------------------
start.sh | 2 --
4 files changed, 7 insertions(+), 87 deletions(-)
delete mode 100644 pyinstaller.spec
delete mode 100755 start.sh
diff --git a/.gitignore b/.gitignore
index bfefc77..345ff43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,12 @@
__pycache__/
+*.pyc
+.env
tinyweb_identity
index.db
index.db-shm
index.db-wal
models/
index.hnsw
+*.db
+*.db-shm
+*.db-wal
diff --git a/app.py b/app.py
index e621322..6e70196 100644
--- a/app.py
+++ b/app.py
@@ -13,8 +13,6 @@ import templates as templates_mod
import gateway
from gateway import GatewayState, GatewayHandler
-APP_NAME = "tinyweb"
-ASPECTS = ["server"]
IDENTITY_FILE = "tinyweb_identity"
DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
DEFAULT_TRANSPORT_PORT = 4242
@@ -265,8 +263,8 @@ def main():
identity,
RNS.Destination.IN,
RNS.Destination.SINGLE,
- APP_NAME,
- *ASPECTS,
+ gateway.APP_NAME,
+ *gateway.ASPECTS,
)
destination.register_request_handler(
diff --git a/pyinstaller.spec b/pyinstaller.spec
deleted file mode 100644
index 431155f..0000000
--- a/pyinstaller.spec
+++ /dev/null
@@ -1,81 +0,0 @@
-# -*- mode: python ; coding: utf-8 -*-
-
-import os
-import sys
-
-block_cipher = None
-
-# Hidden imports that PyInstaller can't detect automatically
-hiddenimports = [
- "RNS",
- "RNS.Destination",
- "RNS.Identity",
- "RNS.Reticulum",
- "onnxruntime",
- "onnxruntime.capi.onnxruntime_pybind11_state",
- "tokenizers",
- "huggingface_hub",
- "hnswlib",
- "bs4",
- "beautifulsoup4",
- "numpy",
- "requests",
-]
-
-# Data files to include
-datas = [
- ("themes", "themes"),
-]
-
-# Exclude unnecessary modules
-excludes = [
- "test",
- "tests",
- "tkinter",
- "matplotlib",
- "scipy",
- "pandas",
- "IPython",
- "jupyter",
- "notebook",
-]
-
-a = Analysis(
- ["app.py"],
- pathex=[],
- binaries=[],
- datas=datas,
- hiddenimports=hiddenimports,
- hookspath=[],
- hooksconfig={},
- runtime_hooks=[],
- excludes=excludes,
- win_no_prefer_redirects=False,
- win_private_assemblies=False,
- cipher=block_cipher,
- noarchive=False,
-)
-
-pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
-
-exe = EXE(
- pyz,
- a.scripts,
- a.binaries,
- a.zipfiles,
- a.datas,
- [],
- name="TinyWeb",
- debug=False,
- bootloader_ignore_signals=False,
- strip=False,
- upx=True,
- upx_exclude=[],
- runtime_tmpdir=None,
- console=True,
- disable_windowed_traceback=False,
- argv_emulation=False,
- target_arch=None,
- codesign_identity=None,
- entitlements_file=None,
-)
diff --git a/start.sh b/start.sh
deleted file mode 100755
index c926310..0000000
--- a/start.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec /nix/store/vhgmnrmvvfdiw0kc2xz8px7rvg60lszc-python3-3.13.12-env/bin/python /home/user/apps/tinyweb/app.py --bind 0.0.0.0
\ No newline at end of file
From 6b24c34056032ddbfa5fa47a74ed8ccd3dd8d69d Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:16:54 +0000
Subject: [PATCH 134/194] clean up: remove unused start.sh/pyinstaller.spec,
deduplicate constants, update .gitignore
---
.gitignore | 5 +++
app.py | 6 ++--
pyinstaller.spec | 81 ------------------------------------------------
start.sh | 2 --
4 files changed, 7 insertions(+), 87 deletions(-)
delete mode 100644 pyinstaller.spec
delete mode 100755 start.sh
diff --git a/.gitignore b/.gitignore
index bfefc77..345ff43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,12 @@
__pycache__/
+*.pyc
+.env
tinyweb_identity
index.db
index.db-shm
index.db-wal
models/
index.hnsw
+*.db
+*.db-shm
+*.db-wal
diff --git a/app.py b/app.py
index e621322..6e70196 100644
--- a/app.py
+++ b/app.py
@@ -13,8 +13,6 @@ import templates as templates_mod
import gateway
from gateway import GatewayState, GatewayHandler
-APP_NAME = "tinyweb"
-ASPECTS = ["server"]
IDENTITY_FILE = "tinyweb_identity"
DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
DEFAULT_TRANSPORT_PORT = 4242
@@ -265,8 +263,8 @@ def main():
identity,
RNS.Destination.IN,
RNS.Destination.SINGLE,
- APP_NAME,
- *ASPECTS,
+ gateway.APP_NAME,
+ *gateway.ASPECTS,
)
destination.register_request_handler(
diff --git a/pyinstaller.spec b/pyinstaller.spec
deleted file mode 100644
index 431155f..0000000
--- a/pyinstaller.spec
+++ /dev/null
@@ -1,81 +0,0 @@
-# -*- mode: python ; coding: utf-8 -*-
-
-import os
-import sys
-
-block_cipher = None
-
-# Hidden imports that PyInstaller can't detect automatically
-hiddenimports = [
- "RNS",
- "RNS.Destination",
- "RNS.Identity",
- "RNS.Reticulum",
- "onnxruntime",
- "onnxruntime.capi.onnxruntime_pybind11_state",
- "tokenizers",
- "huggingface_hub",
- "hnswlib",
- "bs4",
- "beautifulsoup4",
- "numpy",
- "requests",
-]
-
-# Data files to include
-datas = [
- ("themes", "themes"),
-]
-
-# Exclude unnecessary modules
-excludes = [
- "test",
- "tests",
- "tkinter",
- "matplotlib",
- "scipy",
- "pandas",
- "IPython",
- "jupyter",
- "notebook",
-]
-
-a = Analysis(
- ["app.py"],
- pathex=[],
- binaries=[],
- datas=datas,
- hiddenimports=hiddenimports,
- hookspath=[],
- hooksconfig={},
- runtime_hooks=[],
- excludes=excludes,
- win_no_prefer_redirects=False,
- win_private_assemblies=False,
- cipher=block_cipher,
- noarchive=False,
-)
-
-pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
-
-exe = EXE(
- pyz,
- a.scripts,
- a.binaries,
- a.zipfiles,
- a.datas,
- [],
- name="TinyWeb",
- debug=False,
- bootloader_ignore_signals=False,
- strip=False,
- upx=True,
- upx_exclude=[],
- runtime_tmpdir=None,
- console=True,
- disable_windowed_traceback=False,
- argv_emulation=False,
- target_arch=None,
- codesign_identity=None,
- entitlements_file=None,
-)
diff --git a/start.sh b/start.sh
deleted file mode 100755
index c926310..0000000
--- a/start.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec /nix/store/vhgmnrmvvfdiw0kc2xz8px7rvg60lszc-python3-3.13.12-env/bin/python /home/user/apps/tinyweb/app.py --bind 0.0.0.0
\ No newline at end of file
From 997dd20e497b1b74e3a24b10d986d56bc2dfb5ff Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:40:16 +0000
Subject: [PATCH 135/194] point repo URLs to Codeberg instead of self-hosted
Gitea
---
README.md | 8 ++++----
handlers.py | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 116b4f8..90c10c8 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
TinyWeb is distributed as source. Clone the repo, then build and run with Docker Compose:
```bash
-git clone https://git.derickphan.com/blankie/tinyweb.git
+git clone https://codeberg.org/tinyweb/tinyweb.git
cd tinyweb
docker compose up -d
```
@@ -168,7 +168,7 @@ This connects over Reticulum and serves the remote instance at `http://localhost
## Forum plugin
-TinyWeb ships with an optional [tinyweb-forum](https://git.derickphan.com/blankie/tinyweb-forum) plugin — a decentralized link-sharing discussion board that runs in-process alongside TinyWeb.
+TinyWeb ships with an optional [tinyweb-forum](https://codeberg.org/tinyweb/tinyweb-forum) plugin — a decentralized link-sharing discussion board that runs in-process alongside TinyWeb.
### Install
@@ -189,7 +189,7 @@ Enable it on the `/style` page under "Forum". A "Forum" link will appear in the
- Threads are auto-pruned after 30 days (configurable, or set to 0 to keep everything)
- Moderation is local: block authors, mute threads, keyword filters, and gossip block lists with peers (auto-block after 3 peer reports)
-For full feature docs, see the [tinyweb-forum README](https://git.derickphan.com/blankie/tinyweb-forum).
+For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyweb/tinyweb-forum).
## Project structure
@@ -216,7 +216,7 @@ Other hardening measures:
- **XSS escaping** — All user-supplied content is HTML-escaped before rendering
- **Bookmark authentication** — The bookmarklet endpoint requires a secret token
- **Identity file protection** — The Reticulum identity key is restricted to owner-only permissions (0600)
-- **Forum caveats** — See [tinyweb-forum Security](https://git.derickphan.com/blankie/tinyweb-forum#security) for forum-specific risks (voluntary retractions, block gossip manipulation, no rate limiting)
+- **Forum caveats** — See [tinyweb-forum Security](https://codeberg.org/tinyweb/tinyweb-forum#security) for forum-specific risks (voluntary retractions, block gossip manipulation, no rate limiting)
## Maintenance
diff --git a/handlers.py b/handlers.py
index 03b095c..2ece135 100644
--- a/handlers.py
+++ b/handlers.py
@@ -852,7 +852,7 @@ def handle_style_form(msg=""):
f" enable forum (shared URL discussion board) "
f"Share URLs and discuss them with other TinyWeb instances. "
f"Requires tinyweb-forum — "
- f'more info . '
+ f'more info . '
)
else:
forum_section = ""
From 9707079debfe450f092bd2b017085e2ef942a45e Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 01:40:16 +0000
Subject: [PATCH 136/194] point repo URLs to Codeberg instead of self-hosted
Gitea
---
README.md | 8 ++++----
handlers.py | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 116b4f8..90c10c8 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
TinyWeb is distributed as source. Clone the repo, then build and run with Docker Compose:
```bash
-git clone https://git.derickphan.com/blankie/tinyweb.git
+git clone https://codeberg.org/tinyweb/tinyweb.git
cd tinyweb
docker compose up -d
```
@@ -168,7 +168,7 @@ This connects over Reticulum and serves the remote instance at `http://localhost
## Forum plugin
-TinyWeb ships with an optional [tinyweb-forum](https://git.derickphan.com/blankie/tinyweb-forum) plugin — a decentralized link-sharing discussion board that runs in-process alongside TinyWeb.
+TinyWeb ships with an optional [tinyweb-forum](https://codeberg.org/tinyweb/tinyweb-forum) plugin — a decentralized link-sharing discussion board that runs in-process alongside TinyWeb.
### Install
@@ -189,7 +189,7 @@ Enable it on the `/style` page under "Forum". A "Forum" link will appear in the
- Threads are auto-pruned after 30 days (configurable, or set to 0 to keep everything)
- Moderation is local: block authors, mute threads, keyword filters, and gossip block lists with peers (auto-block after 3 peer reports)
-For full feature docs, see the [tinyweb-forum README](https://git.derickphan.com/blankie/tinyweb-forum).
+For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyweb/tinyweb-forum).
## Project structure
@@ -216,7 +216,7 @@ Other hardening measures:
- **XSS escaping** — All user-supplied content is HTML-escaped before rendering
- **Bookmark authentication** — The bookmarklet endpoint requires a secret token
- **Identity file protection** — The Reticulum identity key is restricted to owner-only permissions (0600)
-- **Forum caveats** — See [tinyweb-forum Security](https://git.derickphan.com/blankie/tinyweb-forum#security) for forum-specific risks (voluntary retractions, block gossip manipulation, no rate limiting)
+- **Forum caveats** — See [tinyweb-forum Security](https://codeberg.org/tinyweb/tinyweb-forum#security) for forum-specific risks (voluntary retractions, block gossip manipulation, no rate limiting)
## Maintenance
diff --git a/handlers.py b/handlers.py
index 03b095c..2ece135 100644
--- a/handlers.py
+++ b/handlers.py
@@ -852,7 +852,7 @@ def handle_style_form(msg=""):
f" enable forum (shared URL discussion board) "
f"Share URLs and discuss them with other TinyWeb instances. "
f"Requires tinyweb-forum — "
- f'more info . '
+ f'more info . '
)
else:
forum_section = ""
From f8b5b0a964dadb7f3e7d7fe089df5a27cf307024 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 02:14:17 +0000
Subject: [PATCH 137/194] add .env.example for environment configuration
---
.env.example | 7 +++++++
1 file changed, 7 insertions(+)
create mode 100644 .env.example
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7dc59af
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# Reticulum configuration directory (default: ~/.tinyweb/.reticulum)
+# RNS_CONFIG_DIR=/path/to/reticulum
+
+# Connect to an internet-accessible TCP transport node for mesh connectivity
+# over the public internet (optional — leave unset for local-only mesh)
+# RNS_TCP_HOST=rnode.bre.land
+# RNS_TCP_PORT=4242
From 503ad787ae3a7dd7e541c6d3b5b5388dea0b6473 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 02:14:17 +0000
Subject: [PATCH 138/194] add .env.example for environment configuration
---
.env.example | 7 +++++++
1 file changed, 7 insertions(+)
create mode 100644 .env.example
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7dc59af
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# Reticulum configuration directory (default: ~/.tinyweb/.reticulum)
+# RNS_CONFIG_DIR=/path/to/reticulum
+
+# Connect to an internet-accessible TCP transport node for mesh connectivity
+# over the public internet (optional — leave unset for local-only mesh)
+# RNS_TCP_HOST=rnode.bre.land
+# RNS_TCP_PORT=4242
From 94f4b2d28a6d43b6bfe9e5bc9f96381e93abfd3c Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 02:34:04 +0000
Subject: [PATCH 139/194] split handlers.py into handlers/ package
- _helpers.py: CSRF, FTS sanitizer, pagination, response helpers, tag helpers
- search.py: BM25 + hybrid search, trusted/remote result rendering
- pages.py: add/edit/delete/bulk/bookmark handlers
- subscriptions.py: sync, share preview, API sites, subscription CRUD
- customize.py: settings form, about page
- tags.py: tag list and tag browse handlers
- data.py: export, import, semantic reindex handlers
- __init__.py: dispatch, re-exports, forum_plugin, _request_local
All 58 external symbols re-exported. No changes to app.py, conftest.py,
or any test file.
---
handlers.py | 1822 -------------------------------------
handlers/__init__.py | 200 ++++
handlers/_helpers.py | 167 ++++
handlers/customize.py | 270 ++++++
handlers/data.py | 125 +++
handlers/pages.py | 387 ++++++++
handlers/search.py | 171 ++++
handlers/subscriptions.py | 455 +++++++++
handlers/tags.py | 59 ++
9 files changed, 1834 insertions(+), 1822 deletions(-)
delete mode 100644 handlers.py
create mode 100644 handlers/__init__.py
create mode 100644 handlers/_helpers.py
create mode 100644 handlers/customize.py
create mode 100644 handlers/data.py
create mode 100644 handlers/pages.py
create mode 100644 handlers/search.py
create mode 100644 handlers/subscriptions.py
create mode 100644 handlers/tags.py
diff --git a/handlers.py b/handlers.py
deleted file mode 100644
index 2ece135..0000000
--- a/handlers.py
+++ /dev/null
@@ -1,1822 +0,0 @@
-import json
-import re
-import secrets
-import threading
-from datetime import datetime
-from urllib.parse import unquote
-
-from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
-import templates as templates_mod
-from templates import esc, wrap_page, DEFAULT_TEMPLATE
-from rns_client import fetch_remote_sites
-
-forum_plugin = None
-_request_local = threading.local()
-
-
-def _get_csrf_token():
- return getattr(_request_local, 'csrf_token', '')
-
-
-def _csrf_field():
- return f' '
-
-
-def _check_csrf(body):
- token = body.get("_csrf", [""])[0]
- expected = _get_csrf_token()
- if not expected or not token:
- return False
- return secrets.compare_digest(token, expected)
-
-
-_STOPWORDS = frozenset({
- "a", "an", "the", "and", "or", "but", "is", "are", "was", "were",
- "in", "on", "at", "to", "for", "of", "with", "by", "from", "as",
- "into", "about", "how", "what", "which", "who", "where", "when",
- "do", "does", "did", "be", "been", "being", "have", "has", "had",
- "it", "its", "this", "that", "not", "no", "so", "if", "can", "will",
- "my", "your", "i", "me", "we", "you", "he", "she", "they",
-})
-
-
-def _sanitize_fts_query(query):
- """Escape user input for safe use in FTS5 MATCH.
-
- Splits into individual quoted tokens joined by implicit AND,
- so all words must appear but in any order. Appends * to the
- last token for prefix matching. Stopwords are dropped to avoid
- overly strict matching.
- """
- words = query.split()
- if not words:
- return '""'
- tokens = []
- last_idx = len(words) - 1
- for i, w in enumerate(words):
- # Strip FTS5 special characters (operators, column filter colon) to prevent injection
- cleaned = re.sub(r'["\'\(\)\*\+\-\^~:]', '', w).strip()
- if not cleaned:
- continue
- if cleaned.lower() in _STOPWORDS:
- continue
- # Drop FTS5 operator words so they aren't parsed as operators on the unquoted last token
- if cleaned.upper() in ("AND", "OR", "NOT", "NEAR"):
- continue
- if i == last_idx:
- # Prefix match on the last token for partial word matching
- tokens.append(f"{cleaned}*")
- else:
- tokens.append(f'"{cleaned}"')
- return " ".join(tokens) if tokens else '""'
-
-
-def _get_bookmark_token():
- token = get_setting("bookmark_token")
- if not token:
- token = secrets.token_hex(16)
- set_setting("bookmark_token", token)
- return token
-
-
-def _respond(body_html, status=200, use_default=False):
- return {
- "status": status,
- "content_type": "text/html; charset=utf-8",
- "body": wrap_page(body_html, use_default=use_default),
- "headers": {},
- }
-
-
-def _redirect(location):
- if not location.startswith("/") or location.startswith("//"):
- 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"{status} ", status)
-
-
-PER_PAGE = 10
-BROWSE_PER_PAGE = 50
-
-
-def _paginate(query, key="p"):
- try:
- page = int(query.get(key, ["1"])[0])
- except (ValueError, IndexError):
- page = 1
- return max(1, page)
-
-
-def _page_nav(page, total, base_url, per_page=None):
- per_page = per_page or PER_PAGE
- if total <= per_page:
- return ""
- total_pages = (total + per_page - 1) // per_page
- sep = "&" if "?" in base_url else "?"
- parts = []
- if page > 1:
- parts.append(f'« prev ')
- parts.append(f"page {page} of {total_pages}")
- if page < total_pages:
- parts.append(f'next » ')
- return f''
-
-
-# --- Tag helpers ---
-
-
-def _get_page_tags(page_id, db=None):
- close = False
- if db is None:
- db = get_db()
- close = True
- rows = db.execute(
- "SELECT t.name FROM tags t JOIN page_tags pt ON t.id = pt.tag_id "
- "WHERE pt.page_id = ? ORDER BY t.name", (page_id,)
- ).fetchall()
- if close:
- return_db(db)
- return [r["name"] for r in rows]
-
-
-def _set_page_tags(page_id, tag_string, db=None):
- close = False
- if db is None:
- db = get_db()
- close = True
- db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
- for name in (t.strip().lower() for t in tag_string.split(",") if t.strip()):
- db.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
- tag_id = db.execute("SELECT id FROM tags WHERE name = ?", (name,)).fetchone()["id"]
- db.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
- if close:
- db.commit()
- return_db(db)
-
-
-def _cleanup_orphaned_tags(db):
- """Delete tags that have no page associations."""
- db.execute("DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM page_tags)")
-
-
-# --- Route handlers ---
-
-
-def handle_search(query):
- q = query.get("q", [""])[0].strip()
- page = _paginate(query)
- offset = (page - 1) * PER_PAGE
- db = get_db()
- try:
- count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
- name = get_site_name()
-
- result_html = ""
- trusted_html = ""
- if q:
- # BM25 keyword search with column weights: title=10, body=1, url=5, note=3
- try:
- fts_q = _sanitize_fts_query(q)
- bm25_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 bm25(pages_fts, 10.0, 1.0, 5.0, 3.0) LIMIT 100",
- (fts_q,),
- ).fetchall()
- except Exception:
- bm25_rows = []
-
- # Hybrid search: merge BM25 + semantic via RRF
- bm25_ids = [r["id"] for r in bm25_rows]
- chunk_snippets = {} # page_id -> best chunk text
- if get_setting("semantic_search", "0") == "1":
- try:
- from embeddings import hybrid_search
- use_reranker = get_setting("use_reranker", "1") == "1"
- fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
- fused_ids = [pid for pid, _ in fused]
- chunk_snippets = {pid: text for pid, text in fused if text}
- except Exception:
- fused_ids = bm25_ids
- else:
- fused_ids = bm25_ids
-
- total_results = len(fused_ids)
- page_ids = fused_ids[offset:offset + PER_PAGE]
-
- if page_ids:
- # Fetch rows in fused order
- placeholders = ",".join("?" * len(page_ids))
- all_rows = db.execute(
- f"SELECT id, url, title, body, note, summary FROM pages WHERE id IN ({placeholders})",
- page_ids,
- ).fetchall()
- row_map = {r["id"]: r for r in all_rows}
- rows = [row_map[pid] for pid in page_ids if pid in row_map]
- else:
- rows = []
-
- if rows:
- for r in rows:
- note_html = ""
- if r["note"]:
- note_html = f'{esc(r["note"])}
'
- tags = _get_page_tags(r["id"], db)
- tags_html = ""
- if tags:
- tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
- tags_html = f'{tag_links}
'
- snip_html = f' {esc(r["summary"])}' if r["summary"] else ""
- result_html += (
- f''
- f'
{esc(r["title"])} '
- f'
{esc(r["url"])} '
- f'{snip_html}'
- f'{note_html}{tags_html}'
- f'
'
- )
- else:
- result_html = "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'{esc(l["label"])} '
- f'— from {esc(l["source_title"])} '
- )
- trusted_html = (
- f''
- f'from your trusted sites ({len(trusted)}) '
- f''
- f' '
- )
-
- # search synced pages from subscriptions
- try:
- 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",
- (_sanitize_fts_query(q),),
- ).fetchall()
- except Exception:
- remote_rows = []
-
- 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' — {esc(r["note"])} ' if r["note"] else ""
- source_items += (
- f'{esc(r["title"])} '
- f'{note_html} ({esc(clean_url(r["url"]))}) '
- )
- remote_html += (
- f''
- f'from {esc(source)} ({len(items)}) '
- f''
- f' '
- )
- finally:
- return_db(db)
- 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 = (
- ''
- )
- return _respond(
- f''
- f' '
- f' search '
- f' '
- f'{count} pages indexed'
- f' · + add url
'
- f'{welcome_html}'
- f'{result_html}'
- f'{_page_nav(page, total_results, f"/?q={esc(q)}") if q else ""}'
- f'{trusted_html}{remote_html}'
- )
-
-
-def handle_add_form(msg="", action_type="index", prefill_url=""):
- if action_type == "subscribe":
- return _respond(
- f"subscribe "
- f"Subscribe to a friend's TinyWeb instance to sync their index
"
- f''
- f'{_csrf_field()}'
- f' '
- f'subscribe '
- f" "
- f"or add a single site
"
- f"{msg}
"
- f'back '
- )
- url_value = f'value="{esc(prefill_url)}" ' if prefill_url else ""
- return _respond(
- f"add url "
- f"Add a site to your index
"
- f''
- f'{_csrf_field()}'
- f' '
- f' '
- f' '
- f'tag: private to exclude from sharing '
- f'index '
- f" "
- f"{msg}
"
- f'back '
- )
-
-
-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(">", "")
- 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}"
-
- try:
- title = index_url(url, note, reticulum_dest if reticulum_dest else "")
- if tags:
- db = get_db()
- try:
- row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
- if row:
- _set_page_tags(row["id"], tags, db)
- db.commit()
- finally:
- return_db(db)
-
- return handle_add_form(f'Indexed: {esc(url)}')
-
- except ValueError as e:
- return handle_add_form(f"Error: {esc(str(e))}")
-
- except Exception as e:
- error_msg = str(e).lower()
- # Check if it's a block response
- if "block" in error_msg or "cloudflare" in error_msg or "403" in error_msg:
- # Show manual entry form for blocked sites
- return _respond(
- f"add url (manual entry) "
- f"{esc(url)} blocks automated access. "
- f"You can still save it manually:
"
- f''
- f'{_csrf_field()}'
- f' '
- f' '
- f' '
- f'Title: '
- f' '
- f'Description: '
- f' '
- f'save manually '
- f" "
- f'back '
- )
- return handle_add_form(f"Error: could not fetch or index that URL. {esc(str(e)[:100])}")
-
-
-def handle_add_manual_submit(body):
- url = clean_url(body.get("url", [""])[0].strip())
- note = body.get("note", [""])[0].strip()
- tags = body.get("tags", [""])[0].strip()
- manual_title = body.get("manual_title", [""])[0].strip()
- manual_desc = body.get("manual_description", [""])[0].strip()
-
- if not url:
- return handle_add_form("URL is required.")
-
- if not manual_title:
- return handle_add_form("Title is required for manual entry.")
-
- db = get_db()
- try:
- now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
-
- db.execute(
- "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
- "ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
- "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
- (url, manual_title, manual_desc, note, now, manual_desc[:200]),
- )
-
- # Get the page ID
- page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
-
- # Add tags if provided
- if tags:
- _set_page_tags(page_id, tags, db)
-
- db.commit()
-
- # Generate embeddings for this page (if semantic search is enabled)
- if get_setting("semantic_search", "0") == "1":
- try:
- from embeddings import store_embeddings
- # Pass the page_id, title, description, and db connection
- store_embeddings(page_id, manual_title, manual_desc, db)
- db.commit()
- except Exception as e:
- # Log error but don't fail the whole operation
- print(f"Error generating embeddings: {e}")
-
- return handle_add_form(f'Added manually: {esc(manual_title)} ')
- finally:
- return_db(db)
-
-
-def handle_pages(query=None):
- msg = query.get("msg", [""])[0] if query else ""
- msg_html = f'{esc(msg)}
' if msg else ""
- page = _paginate(query or {})
- offset = (page - 1) * BROWSE_PER_PAGE
- db = get_db()
- try:
- total = db.execute("SELECT count(*) FROM pages").fetchone()[0]
- rows = db.execute(
- "SELECT id, url, title, note FROM pages ORDER BY id DESC LIMIT ? OFFSET ?",
- (BROWSE_PER_PAGE, offset),
- ).fetchall()
- items = ""
- for r in rows:
- note_html = f' — {esc(r["note"])} ' if r["note"] else ""
- tags = _get_page_tags(r["id"], db)
- tags_html = ""
- if tags:
- tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
- tags_html = f' {tag_links}'
- items += (
- f' '
- f'{esc(r["title"])} {note_html}{tags_html} '
- f'({esc(r["url"])} ) '
- f'edit '
- f'remove '
- )
- finally:
- return_db(db)
- return _respond(
- f"indexed pages ({total}) "
- f"{msg_html}"
- f''
- f'{_csrf_field()}'
- f' select all
'
- f""
- f'{_page_nav(page, total, "/pages", BROWSE_PER_PAGE)}'
- f'bulk actions '
- f'delete selected
'
- f' '
- f'add tags replace tags '
- f'retag selected
'
- f' '
- f' '
- f''
- f'export | import
'
- f'back '
- )
-
-
-def _render_bulk_delete_confirm(page_ids):
- """Server-side confirmation page for bulk deletion — mirrors handle_delete_confirm."""
- db = get_db()
- try:
- placeholders = ",".join("?" * len(page_ids))
- rows = db.execute(
- f"SELECT id, url, title FROM pages WHERE id IN ({placeholders})",
- page_ids,
- ).fetchall()
- finally:
- return_db(db)
- if not rows:
- return _redirect("/pages")
- items = "".join(
- f'{esc(r["title"] or r["url"])} '
- f'{esc(r["url"])} '
- for r in rows
- )
- hidden_ids = "".join(
- f' ' for r in rows
- )
- n = len(rows)
- return _respond(
- f"confirm delete "
- f"Remove the following {n} page{'' if n == 1 else 's'}?
"
- f""
- f''
- f'{_csrf_field()}'
- f'{hidden_ids}'
- f' '
- f' '
- f'yes, delete {n} page{"" if n == 1 else "s"} '
- f" "
- f' cancel '
- )
-
-
-def handle_bulk_action(body):
- ids = body.get("ids", [])
- action = body.get("action", [""])[0]
- if not ids:
- return _redirect("/pages")
- # Validate all ids are integers
- try:
- page_ids = [int(i) for i in ids]
- except ValueError:
- return _error(400)
- # Require an explicit second-step confirmation for bulk delete — the JS
- # confirm() on /pages is a first-line filter only.
- if action == "delete" and body.get("confirmed", [""])[0] != "1":
- return _render_bulk_delete_confirm(page_ids)
- db = get_db()
- try:
- if action == "delete":
- for pid in page_ids:
- db.execute("DELETE FROM page_tags WHERE page_id = ?", (pid,))
- db.execute("DELETE FROM links WHERE page_id = ?", (pid,))
- db.execute("DELETE FROM pages WHERE id = ?", (pid,))
- _cleanup_orphaned_tags(db)
- db.commit()
- elif action == "retag":
- bulk_tags = body.get("bulk_tags", [""])[0].strip()
- tag_mode = body.get("tag_mode", ["add"])[0]
- if bulk_tags:
- for pid in page_ids:
- if tag_mode == "add":
- existing = _get_page_tags(pid, db)
- new_tags = [t.strip().lower() for t in bulk_tags.split(",") if t.strip()]
- merged = ", ".join(sorted(set(existing + new_tags)))
- _set_page_tags(pid, merged, db)
- else:
- _set_page_tags(pid, bulk_tags, db)
- _cleanup_orphaned_tags(db)
- db.commit()
- finally:
- return_db(db)
- return _redirect("/pages")
-
-
-def handle_edit_form(page_id, msg=""):
- db = get_db()
- try:
- row = db.execute("SELECT id, url, title, body, note, summary FROM pages WHERE id = ?", (page_id,)).fetchone()
- if not row:
- return _error(404)
- tags = ", ".join(_get_page_tags(page_id, db))
- finally:
- return_db(db)
-
- return _respond(
- f"edit page "
- f"{esc(row['title'])} "
- f"{esc(row['url'])}
"
- f''
- f'{_csrf_field()}'
- f'Title: '
- f' '
- f'Summary (shown in search results): '
- f'{esc(row["summary"] or "")} '
- f'Note (why you saved this): '
- f' '
- f'Tags (comma-separated): '
- f' '
- f'(tag: private to keep private) '
- f'save '
- f" "
- f"{msg}
"
- f'back '
- )
-
-
-def handle_edit_submit(page_id, body):
- title = body.get("title", [""])[0].strip()
- summary = body.get("summary", [""])[0].strip()
- note = body.get("note", [""])[0].strip()
- tags = body.get("tags", [""])[0].strip()
-
- db = get_db()
- try:
- db.execute(
- "UPDATE pages SET title = ?, summary = ?, note = ? WHERE id = ?",
- (title, summary, note, page_id)
- )
-
- _set_page_tags(page_id, tags, db)
- _cleanup_orphaned_tags(db)
-
- db.commit()
-
- finally:
- return_db(db)
-
- return _redirect("/pages")
-
-
-def handle_delete_confirm(page_id):
- db = get_db()
- try:
- row = db.execute("SELECT id, url, title FROM pages WHERE id = ?", (page_id,)).fetchone()
- finally:
- return_db(db)
- if not row:
- return _error(404)
- return _respond(
- f"confirm delete "
- f"Remove {esc(row['title'])} "
- f"{esc(row['url'])}
"
- f''
- f'{_csrf_field()}'
- f'yes, delete '
- f" "
- f' cancel '
- )
-
-
-def handle_delete(page_id):
- db = get_db()
- try:
- db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
- db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
- db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
- _cleanup_orphaned_tags(db)
- db.commit()
- finally:
- return_db(db)
- return _redirect("/pages")
-
-
-def handle_bookmark(query):
- token = query.get("token", [""])[0]
- expected = _get_bookmark_token()
- if not token or not secrets.compare_digest(token, expected):
- return _text_response("error: invalid or missing token", status=403, headers={"Access-Control-Allow-Origin": "*"})
- url = clean_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": "*"})
-
-
-MAX_EXPORT = 10000
-
-def handle_export(query=None):
- try:
- batch = int((query or {}).get("batch", ["0"])[0])
- except (TypeError, ValueError):
- batch = 0
- db = get_db()
- try:
- rows = db.execute(
- "SELECT url, title, note FROM pages ORDER BY id LIMIT ? OFFSET ?",
- (MAX_EXPORT, batch * MAX_EXPORT),
- ).fetchall()
- finally:
- return_db(db)
- 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"import "
- f"Paste the contents of a tinyweb export file (JSON).
"
- f''
- f'{_csrf_field()}'
- f' '
- f'import '
- 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.")
-
- MAX_IMPORT = 100
- if len(data) > MAX_IMPORT:
- return handle_import_form(f"Too many entries. Maximum is {MAX_IMPORT}.")
-
- 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=""):
- template = get_setting("custom_template") or DEFAULT_TEMPLATE
- name = get_site_name()
- sharing = get_setting("sharing_enabled", "0")
- checked = " checked" if sharing == "1" else ""
- sharing_mode = get_setting("sharing_mode", "exclude_private")
- forum = get_setting("forum_enabled", "0")
- forum_checked = " checked" if forum == "1" else ""
- exclude_checked = " checked" if sharing_mode != "require_public" else ""
- require_checked = " checked" if sharing_mode == "require_public" else ""
- shared_count = _count_shared_pages()
- semantic = get_setting("semantic_search", "0")
- semantic_checked = " checked" if semantic == "1" else ""
- reranker = get_setting("use_reranker", "0")
- reranker_checked = " checked" if reranker == "1" else ""
- disabled = "" if semantic == "1" else " disabled"
- dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
- tcp_enabled = get_setting("tcp_enabled", "1")
- tcp_checked = " checked" if tcp_enabled == "1" else ""
- tcp_disabled = "" if tcp_enabled == "1" else " disabled"
- transport_host = get_setting("transport_host", "rnode.bre.land")
- transport_port = get_setting("transport_port", "4242")
- compress = get_setting("compress_embeddings", "0")
- compress_checked = " checked" if compress == "1" else ""
- lora_enabled = get_setting("lora_enabled", "0")
- lora_checked = " checked" if lora_enabled == "1" else ""
- lora_disabled = "" if lora_enabled == "1" else " disabled"
- lora_dimmed = ' style="opacity:0.4"' if lora_enabled != "1" else ""
- lora_port = get_setting("lora_port", "")
- lora_frequency = get_setting("lora_frequency", "867200000")
- lora_bandwidth = get_setting("lora_bandwidth", "125000")
- lora_txpower = get_setting("lora_txpower", "7")
- lora_sf = get_setting("lora_sf", "8")
- lora_cr = get_setting("lora_cr", "5")
- if forum_plugin is not None:
- forum_section = (
- f"forum "
- f' '
- f" enable forum (shared URL discussion board) "
- f"Share URLs and discuss them with other TinyWeb instances. "
- f"Requires tinyweb-forum — "
- f'more info . '
- )
- else:
- forum_section = ""
- return _respond(
- f"customize "
- f"name your search engine "
- f''
- f'{_csrf_field()}'
- f' '
- f"sharing "
- f' '
- f" share your site list publicly at /api/sites "
- f''
- f"What to share: "
- f' '
- f' share all pages except those tagged private '
- f' '
- f' share only pages tagged public '
- f'The private tag always excludes a page, even in public-only mode. '
- f'
'
- f''
- f'Currently sharing {shared_count} page(s). '
- f'preview what subscribers would see '
- f'
'
- f"mesh network "
- f"Choose how to connect to the mesh. You can enable both for maximum reach.
"
- f"internet "
- f' '
- f" connect via internet transport node "
- f"Reach peers anywhere online. "
- f' '
- f"LoRa "
- f' '
- f" connect via LoRa radio "
- f"Reach nearby peers off-grid with an RNode . "
- f''
- f'Serial port: '
- f'advanced radio settings '
- f'
'
- f"search "
- f"ai "
- f' '
- f" semantic search (similarity matching) "
- f"Requires onnxruntime, tokenizers, hnswlib. Downloads ~30MB of models on first use. "
- f'"
- f"{forum_section}"
- f"custom html "
- f"Edit the full page template. Use {esc('{{content}}')} "
- f"where page content should appear.
"
- f'{esc(template)} '
- f'save '
- f" "
- f"bookmarklet "
- f"Drag this link to your bookmarks bar. Click it on any page to index it instantly.
"
- f'+ save to {esc(name)}
'
- f"reset "
- f''
- f'{_csrf_field()}'
- f'reset template to default '
- f" "
- f"maintenance "
- f''
- f'{_csrf_field()}'
- f'vacuum database '
- f" "
- f"{msg}
"
- f'back ',
- use_default=True,
- )
-
-
-def handle_style_submit(body):
- template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
- name = body.get("site_name", ["tinyweb"])[0].strip()
- sharing = "1" if body.get("sharing_enabled") else "0"
- sharing_mode = body.get("sharing_mode", ["exclude_private"])[0]
- if sharing_mode not in ("exclude_private", "require_public"):
- sharing_mode = "exclude_private"
- set_setting("sharing_mode", sharing_mode)
- semantic = "1" if body.get("semantic_search") else "0"
- reranker = "1" if body.get("use_reranker") else "0"
- compress = "1" if body.get("compress_embeddings") else "0"
- tcp_enabled = "1" if body.get("tcp_enabled") else "0"
- transport_host = body.get("transport_host", [""])[0].strip()
- transport_port = body.get("transport_port", [""])[0].strip()
- set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
- set_setting("site_name", name or "tinyweb")
- set_setting("sharing_enabled", sharing)
- set_setting("semantic_search", semantic)
- set_setting("use_reranker", reranker)
- set_setting("compress_embeddings", compress)
- set_setting("tcp_enabled", tcp_enabled)
- if transport_host:
- set_setting("transport_host", transport_host)
- if transport_port:
- set_setting("transport_port", transport_port)
- lora_enabled = "1" if body.get("lora_enabled") else "0"
- set_setting("lora_enabled", lora_enabled)
- set_setting("lora_port", body.get("lora_port", [""])[0].strip())
- set_setting("lora_frequency", body.get("lora_frequency", ["867200000"])[0].strip())
- set_setting("lora_bandwidth", body.get("lora_bandwidth", ["125000"])[0].strip())
- set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
- set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
- set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
- forum_enabled = "1" if body.get("forum_enabled") else "0"
- current_forum = get_setting("forum_enabled", "0")
- if forum_enabled != current_forum:
- if forum_enabled == "1" and forum_plugin is None:
- return handle_style_form(
- "Forum plugin not installed. Run: pip install tinyweb-forum"
- )
- if forum_enabled == "1":
- forum_plugin.enable()
- try:
- forum_plugin.fdb.set_setting("forum_enabled", "1")
- except Exception:
- pass
- else:
- forum_plugin.disable()
- try:
- forum_plugin.fdb.set_setting("forum_enabled", "0")
- except Exception:
- pass
- set_setting("forum_enabled", forum_enabled)
- templates_mod.FORUM_ENABLED = (forum_enabled == "1")
- return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.")
-
-
-def handle_about():
- name = get_site_name()
- dest_hash = get_setting("dest_hash")
- sharing = get_setting("sharing_enabled", "0") == "1"
- db = get_db()
- try:
- page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
- tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0]
- sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0]
- finally:
- return_db(db)
-
- sharing_html = (
- 'This instance shares its index publicly. Subscribe to join the network.
'
- if sharing else
- 'This instance is private.
'
- )
-
- hash_html = ""
- if dest_hash:
- hash_html = (
- f'subscribe '
- f'To subscribe to this instance, add this destination hash in your TinyWeb:
'
- f'{esc(dest_hash)} '
- )
-
- return _respond(
- f'{esc(name)} '
- f'A personal, decentralized search engine.
'
- f'You save pages you find. They are stored locally and shared over a mesh network '
- f'so other people can find them too.
'
- f'Search results come from your index and the indexes of people you are connected to.
'
- f''
- f'{page_count} page(s) indexed '
- f'{tag_count} tag(s) '
- f'{sub_count} subscription(s) '
- f' '
- f'{sharing_html}'
- f'{hash_html}'
- f'your data '
- f'Everything is stored locally under ~/.tinyweb/:
'
- f''
- f'tinyweb_identity — your permanent mesh identity. '
- f'If you lose this file, your destination hash changes and subscribers '
- f'have to re-subscribe to the new one. '
- f'index.db — your full reading history: every page, '
- f'note, tag, and synced remote page. '
- f'models/ — the semantic search model if you enabled it '
- f'(redownloadable, safe to delete). '
- f' '
- f'Back up ~/.tinyweb/ periodically. '
- f'Copying the whole directory to another device preserves your identity and index together. '
- f'The export page gives you a JSON dump of pages only — '
- f'it does not preserve your identity or subscription state, so it is a migration aid, '
- f'not a substitute for a full backup.
'
- f'search | browse | tags
'
- )
-
-
-def handle_tags():
- db = get_db()
- try:
- rows = db.execute(
- "SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t "
- "JOIN page_tags pt ON t.id = pt.tag_id "
- "GROUP BY t.id ORDER BY t.name"
- ).fetchall()
- finally:
- return_db(db)
- items = ""
- for r in rows:
- items += f'{esc(r["name"])} ({r["cnt"]}) '
- return _respond(
- f"tags "
- f"" if items else "No tags yet. Add tags when saving or editing pages.
"
- f'back '
- )
-
-
-def handle_tag_browse(tag_name, query=None):
- page = _paginate(query or {})
- offset = (page - 1) * BROWSE_PER_PAGE
- db = get_db()
- try:
- total = db.execute(
- "SELECT count(*) FROM page_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ?",
- (tag_name,),
- ).fetchone()[0]
- rows = db.execute(
- "SELECT p.id, p.url, p.title, p.note FROM pages p "
- "JOIN page_tags pt ON p.id = pt.page_id "
- "JOIN tags t ON t.id = pt.tag_id "
- "WHERE t.name = ? ORDER BY p.id DESC LIMIT ? OFFSET ?",
- (tag_name, BROWSE_PER_PAGE, offset),
- ).fetchall()
- items = ""
- for r in rows:
- note_html = f' — {esc(r["note"])} ' if r["note"] else ""
- tags = _get_page_tags(r["id"], db)
- tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
- items += (
- f'{esc(r["title"])}{note_html} {tag_links} '
- f'({esc(r["url"])} ) '
- )
- finally:
- return_db(db)
- return _respond(
- f'tag: {esc(tag_name)} '
- f'{total} page(s)
'
- f''
- f'{_page_nav(page, total, f"/tags/{esc(tag_name)}", BROWSE_PER_PAGE)}'
- f'all tags | back '
- )
-
-
-MAX_API_SITES = 5000
-
-
-def _page_is_shared(tags, mode):
- """Decide whether a page with the given tags is shared under the given mode.
-
- `private` always wins — a page tagged private is never shared, regardless of mode.
- """
- if "private" in tags:
- return False
- if mode == "require_public" and "public" not in tags:
- return False
- return True
-
-
-def _shared_sites(db, since=""):
- """Return the full site records that a subscriber would receive.
-
- The caller owns the db connection.
- """
- mode = get_setting("sharing_mode", "exclude_private")
- if since:
- rows = db.execute(
- "SELECT id, url, title, note, last_modified FROM pages "
- "WHERE last_modified > ? ORDER BY id DESC LIMIT ?",
- (since, MAX_API_SITES),
- ).fetchall()
- else:
- rows = db.execute(
- "SELECT id, url, title, note, last_modified FROM pages ORDER BY id DESC LIMIT ?",
- (MAX_API_SITES,),
- ).fetchall()
- sites = []
- for r in rows:
- tags = _get_page_tags(r["id"], db)
- if not _page_is_shared(tags, mode):
- continue
- sites.append({
- "url": r["url"], "title": r["title"], "note": r["note"],
- "tags": tags, "last_modified": r["last_modified"] or "",
- })
- return sites
-
-
-def _shared_all_urls(db):
- """Return the URL list a subscriber uses to detect deletions."""
- mode = get_setting("sharing_mode", "exclude_private")
- rows = db.execute(
- "SELECT id, url FROM pages ORDER BY id DESC LIMIT ?", (MAX_API_SITES,)
- ).fetchall()
- return [r["url"] for r in rows if _page_is_shared(_get_page_tags(r["id"], db), mode)]
-
-
-def _count_shared_pages():
- """Cheap page count under the current sharing rule — used by the settings UI."""
- db = get_db()
- try:
- return len(_shared_all_urls(db))
- finally:
- return_db(db)
-
-
-def handle_share_preview():
- """Show the list of pages a subscriber would currently receive.
-
- Works regardless of whether sharing is enabled — lets the user see the surface
- before flipping it on.
- """
- mode = get_setting("sharing_mode", "exclude_private")
- mode_label = (
- "only pages tagged public"
- if mode == "require_public"
- else "all pages except those tagged private"
- )
- sharing_on = get_setting("sharing_enabled", "0") == "1"
- status = (
- 'Sharing is enabled . Subscribers see the pages listed below.
'
- if sharing_on else
- 'Sharing is disabled . Nothing is actually being shared right now; '
- 'this is the list that would be exposed if you enabled it.
'
- )
- db = get_db()
- try:
- sites = _shared_sites(db)
- finally:
- return_db(db)
- if not sites:
- body = (
- "sharing preview "
- f"Rule: {mode_label}.
"
- f"{status}"
- "No pages match the current rule.
"
- 'back to settings
'
- )
- return _respond(body)
- rows = ""
- for s in sites:
- tags_html = ""
- if s["tags"]:
- tags_html = " " + " ".join(f"[{esc(t)}]" for t in s["tags"])
- note_html = f' — {esc(s["note"])} ' if s["note"] else ""
- rows += (
- f''
- f'{esc(s["title"] or s["url"])} '
- f'{note_html}{tags_html} '
- f'{esc(s["url"])} '
- f' '
- )
- body = (
- "sharing preview "
- f"Rule: {mode_label}.
"
- f"{status}"
- f"{len(sites)} page(s) visible to subscribers.
"
- f""
- 'back to settings
'
- )
- return _respond(body)
-
-
-def handle_api_sites(query=None):
- if get_setting("sharing_enabled", "0") != "1":
- return _json_response(
- {"error": "sharing disabled"},
- status=403,
- headers={"Access-Control-Allow-Origin": "*"},
- )
- since = (query or {}).get("since", [""])[0].strip()
- db = get_db()
- try:
- sites = _shared_sites(db, since=since)
- all_urls = _shared_all_urls(db) if not since else None
- finally:
- return_db(db)
- data = {"name": get_site_name(), "sites": sites}
- if all_urls is not None:
- data["all_urls"] = all_urls
- return _json_response(data, headers={"Access-Control-Allow-Origin": "*"})
-
-
-_sync_threads = {}
-
-
-def handle_subscriptions(msg=""):
- db = get_db()
- try:
- subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
- finally:
- return_db(db)
- cards = ""
- for s in subs:
- sub_id = s["id"]
- auto_label = "on" if s["auto_sync"] else "off"
- last = s["last_sync"] or "never"
- sync_status = get_setting(f"sync_status_{sub_id}", "")
- is_syncing = sub_id in _sync_threads and _sync_threads[sub_id].is_alive()
-
- # Status line: show syncing indicator or last result
- if is_syncing:
- status_html = 'syncing...
'
- elif sync_status.startswith("error:"):
- err_msg = sync_status[6:]
- status_html = f'{esc(err_msg)}
'
- else:
- status_html = ""
-
- # Disable sync button while syncing
- if is_syncing:
- sync_btn = 'syncing... '
- else:
- sync_btn = (
- f''
- f'{_csrf_field()}sync now '
- )
-
- cards += (
- f''
- f'
{esc(s["name"] or "unknown")}
'
- f'
{esc(s["dest_hash"])}
'
- f'
last sync: {esc(last)}
'
- f'{status_html}'
- f'
'
- f'
browse '
- f'{sync_btn}'
- f'
'
- f'{_csrf_field()}auto-sync: {auto_label} '
- f'
'
- f'{_csrf_field()}remove '
- f'
'
- f'
'
- )
- listing = ""
- if subs:
- any_syncing = any(sid in _sync_threads and _sync_threads[sid].is_alive() for sid in [s["id"] for s in subs])
- syncall_btn = 'syncing... ' if any_syncing else 'sync all '
- listing = (
- f'{cards}'
- f''
- f'{_csrf_field()}{syncall_btn} '
- )
- return _respond(
- f"subscriptions "
- f''
- f'{_csrf_field()}'
- f' '
- f'subscribe '
- f' '
- f'or subscribe to an instance
'
- f'{msg}
'
- f' {listing}'
- f'back '
- )
-
-
-def handle_subscription_add(body):
- 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:
- 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:
- return handle_subscriptions("Could not reach that instance.")
- db = get_db()
- try:
- db.execute(
- "INSERT INTO subscriptions (dest_hash, name) VALUES (?, ?) "
- "ON CONFLICT(dest_hash) DO UPDATE SET name=excluded.name",
- (dest_hash, name),
- )
- db.commit()
- finally:
- return_db(db)
- return handle_subscriptions(f"Subscribed to {esc(name or dest_hash)}.")
-
-
-MAX_BROWSE = 5000
-
-def handle_subscription_browse(sub_id):
- db = get_db()
- try:
- sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
- if not sub:
- return _error(404)
- local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages LIMIT ?", (MAX_BROWSE,)).fetchall())
-
- # Use locally synced data if available, otherwise fetch live
- remote_rows = db.execute(
- "SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ? LIMIT ?",
- (sub_id, MAX_BROWSE),
- ).fetchall()
- finally:
- return_db(db)
-
- if remote_rows:
- sites = []
- for r in remote_rows:
- tags = [t for t in r["tags"].split(",") if t] if r["tags"] else []
- sites.append({"url": r["url"], "title": r["title"], "note": r["note"], "tags": tags})
- else:
- try:
- data = fetch_remote_sites(sub["dest_hash"])
- sites = data.get("sites", [])
- except PermissionError:
- return handle_subscriptions("That instance has sharing disabled.")
- except Exception:
- return handle_subscriptions("Could not fetch sites from that instance.")
-
- new_items = ""
- existing_items = ""
- new_count = 0
- for s in sites:
- if s["url"] in local_urls:
- existing_items += (
- f'{esc(s["title"])} '
- f'({esc(s["url"])}) — already indexed '
- )
- else:
- new_count += 1
- note_html = f' — {esc(s["note"])} ' if s.get("note") else ""
- tags_html = ""
- if s.get("tags"):
- tags_html = " " + " ".join(f'[{esc(t)}]' for t in s["tags"])
- new_items += (
- f' '
- f' {esc(s["title"])}{note_html}{tags_html}'
- f' ({esc(s["url"])}) '
- )
-
- buttons = ""
- if new_count:
- buttons = 'import selected import all new '
- return _respond(
- f'browsing: {esc(sub["name"] or sub["dest_hash"])} '
- f'{len(sites)} site(s) available, {new_count} new
'
- f''
- f'{_csrf_field()}'
- f' '
- f''
- f'{buttons}'
- f' '
- f'already indexed '
- f'back '
- )
-
-
-def handle_subscription_pick(body):
- sub_id = body.get("sub_id", [""])[0]
- import_all = body.get("import_all", [""])[0]
-
- # Build a url->tags map from remote_pages for this subscription
- db = get_db()
- try:
- remote_rows = db.execute(
- "SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,)
- ).fetchall()
- remote_tags = {r["url"]: r["tags"] for r in remote_rows}
-
- if import_all:
- local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages LIMIT ?", (MAX_BROWSE,)).fetchall())
- urls = [r["url"] for r in remote_rows if r["url"] not in local_urls]
- else:
- urls = body.get("urls", [])
- finally:
- return_db(db)
-
- if not urls:
- return handle_subscriptions("No sites selected.")
-
- imported = 0
- errors = 0
- for url in urls:
- try:
- index_url(url)
- # Import tags from the remote page
- tags_str = remote_tags.get(url, "")
- if tags_str:
- db = get_db()
- try:
- row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
- if row:
- _set_page_tags(row["id"], tags_str, db)
- db.commit()
- finally:
- return_db(db)
- imported += 1
- except Exception:
- errors += 1
- return handle_subscriptions(f"Imported {imported} page(s). {errors} error(s).")
-
-
-def _sync_subscription(sub_id):
- """Run a single subscription sync. Designed to run in a background thread."""
- set_setting(f"sync_status_{sub_id}", "syncing")
- db = get_db()
- try:
- sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
- if not sub:
- set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
- return
- since = sub["last_sync"].replace(" ", "T") if sub["last_sync"] else ""
- try:
- data = fetch_remote_sites(sub["dest_hash"], since=since)
- sites = data.get("sites", [])
- all_urls = data.get("all_urls")
- remote_name = data.get("name", sub["name"])
- except PermissionError:
- set_setting(f"sync_status_{sub_id}", "error:That instance has sharing disabled.")
- return
- except Exception as e:
- set_setting(f"sync_status_{sub_id}", f"error:Could not sync \u2014 {e}")
- return
-
- if all_urls is not None:
- existing = db.execute(
- "SELECT id, url FROM remote_pages WHERE subscription_id = ?", (sub_id,)
- ).fetchall()
- remote_url_set = set(all_urls)
- for row in existing:
- if row["url"] not in remote_url_set:
- db.execute("DELETE FROM remote_pages WHERE id = ?", (row["id"],))
-
- synced = 0
- for s in sites:
- try:
- tags_str = ",".join(s.get("tags", []))
- db.execute(
- "INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?) "
- "ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
- (sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
- )
- if get_setting("semantic_search", "0") == "1":
- try:
- from embeddings import store_remote_embeddings
- rp_id = db.execute(
- "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
- (sub_id, s["url"]),
- ).fetchone()["id"]
- store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
- except Exception:
- pass
- synced += 1
- except Exception:
- pass
- now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
- db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
- db.commit()
- set_setting(f"sync_status_{sub_id}", f"done:{synced}")
- except Exception as e:
- set_setting(f"sync_status_{sub_id}", f"error:{e}")
- finally:
- return_db(db)
-
-
-def handle_subscription_sync(sub_id):
- if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
- return _redirect("/subscriptions")
- # Clear previous status
- set_setting(f"sync_status_{sub_id}", "syncing")
- t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
- _sync_threads[sub_id] = t
- t.start()
- return _redirect("/subscriptions")
-
-
-def handle_subscription_autosync(sub_id):
- db = get_db()
- try:
- db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,))
- db.commit()
- finally:
- return_db(db)
- return _redirect("/subscriptions")
-
-
-def handle_subscription_delete(sub_id):
- db = get_db()
- try:
- db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
- db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,))
- db.commit()
- finally:
- return_db(db)
- return _redirect("/subscriptions")
-
-
-def handle_subscription_syncall():
- db = get_db()
- try:
- subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall()
- finally:
- return_db(db)
- if not subs:
- return handle_subscriptions("No subscriptions have auto-sync enabled.")
- for sub in subs:
- sub_id = sub["id"]
- if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
- continue
- set_setting(f"sync_status_{sub_id}", "syncing")
- t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
- _sync_threads[sub_id] = t
- t.start()
- return _redirect("/subscriptions")
-
-
-# --- Reindex (semantic search) ---
-
-
-_reindex_thread = None
-
-
-def handle_reindex_form():
- if get_setting("semantic_search", "0") != "1":
- return _respond(
- f"semantic search index "
- f"Semantic search is disabled. Enable it in settings to use embeddings.
"
- f'back to search
'
- )
- db = get_db()
- try:
- total_pages = db.execute("SELECT count(*) FROM pages").fetchone()[0]
- pages_with_chunks = db.execute(
- "SELECT count(DISTINCT page_id) FROM chunks WHERE page_id IS NOT NULL"
- ).fetchone()[0]
- finally:
- return_db(db)
- progress = get_setting("reindex_progress", "")
- status_html = ""
- if progress:
- status_html = f'Reindex in progress: {esc(progress)}
'
- elif _reindex_thread and _reindex_thread.is_alive():
- status_html = 'Reindex running...
'
- return _respond(
- f"semantic search index "
- f"{pages_with_chunks} of {total_pages} pages have embeddings.
"
- f'{status_html}'
- f''
- f'{_csrf_field()}'
- f'reindex all pages '
- f' '
- f'back to search
'
- )
-
-
-def handle_reindex_submit(body):
- global _reindex_thread
- if _reindex_thread and _reindex_thread.is_alive():
- return handle_reindex_form()
-
- def _run():
- try:
- from embeddings import reindex_all
- def progress(current, total):
- set_setting("reindex_progress", f"{current}/{total}")
- reindex_all(progress_callback=progress)
- except Exception:
- pass
- finally:
- set_setting("reindex_progress", "")
-
- _reindex_thread = threading.Thread(target=_run, daemon=True)
- _reindex_thread.start()
- return _redirect("/reindex")
-
-
-# --- Dispatcher ---
-
-
-def _dispatch_inner(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":
- action_type = query.get("type", ["index"])[0]
- prefill_url = query.get("url", [""])[0].strip()
- return handle_add_form(
- action_type=action_type if action_type == "subscribe" else "index",
- prefill_url=prefill_url,
- )
- elif path == "/pages":
- return handle_pages(query)
- 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_confirm(pid) if pid is not None else _error(400)
- elif path == "/bookmark":
- return handle_bookmark(query)
- elif path == "/style":
- return handle_style_form()
- elif path == "/share/preview":
- return handle_share_preview()
- elif path == "/about":
- return handle_about()
- elif path == "/export":
- return handle_export(query)
- elif path == "/import":
- return handle_import_form()
- elif path == "/tags":
- return handle_tags()
- elif path.startswith("/tags/"):
- tag_name = unquote(path[len("/tags/"):])
- return handle_tag_browse(tag_name, query) if tag_name else _error(400)
- elif path == "/reindex":
- return handle_reindex_form()
- elif path == "/api/sites":
- return handle_api_sites(query)
- 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 path.startswith("/forum"):
- if forum_plugin and forum_plugin.is_enabled():
- return forum_plugin.handle(method, path, query, {}, data.get("cookies", {}))
- return _error(404)
- elif method == "POST":
- if path.startswith("/forum"):
- if forum_plugin and forum_plugin.is_enabled():
- return forum_plugin.handle(method, path, query, body, data.get("cookies", {}))
- return _error(404)
- if not _check_csrf(body):
- return _respond("403 Forbidden Invalid or missing CSRF token.
", status=403)
- if path == "/add":
- return handle_add_submit(body)
- elif path == "/pages/bulk":
- return handle_bulk_action(body)
- elif path == "/add/manual":
- return handle_add_manual_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.startswith("/delete/"):
- pid = extract_id("/delete/")
- return handle_delete(pid) if pid is not None else _error(400)
- elif path == "/style":
- return handle_style_submit(body)
- elif path == "/style/reset":
- set_setting("custom_template", "")
- return handle_style_form("Template reset to default.")
- elif path == "/style/vacuum":
- from db import vacuum_db
- vacuum_db()
- return handle_style_form("Database vacuumed.")
- elif path == "/import":
- return handle_import_submit(body)
- elif path == "/reindex":
- return handle_reindex_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)
-
-
-def dispatch_request(data):
- path = data.get("path", "/")
- cookies = data.get("cookies", {})
-
- # Forum handles its own CSRF — skip main CSRF to avoid cookie conflicts
- if path.startswith("/forum") and forum_plugin and forum_plugin.is_enabled():
- resp = _dispatch_inner(data)
- resp.setdefault("headers", {})
- resp["headers"]["X-Frame-Options"] = "DENY"
- resp["headers"]["X-Content-Type-Options"] = "nosniff"
- if resp.get("content_type", "").startswith("text/html"):
- resp["body"] = wrap_page(resp.get("body", ""))
- resp["headers"]["Content-Security-Policy"] = (
- "default-src 'self'; "
- "script-src 'self' 'unsafe-inline'; "
- "style-src 'self' 'unsafe-inline'; "
- "img-src * data:; "
- "frame-ancestors 'none'; "
- "form-action 'self'; "
- "base-uri 'self'"
- )
- return resp
-
- csrf_token = cookies.get("_csrf", "")
- if not csrf_token:
- csrf_token = secrets.token_hex(32)
- _request_local.csrf_token = csrf_token
-
- resp = _dispatch_inner(data)
-
- resp.setdefault("headers", {})
- resp["headers"]["Set-Cookie"] = f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/"
- resp["headers"]["X-Frame-Options"] = "DENY"
- resp["headers"]["X-Content-Type-Options"] = "nosniff"
- if resp.get("content_type", "").startswith("text/html"):
- resp["headers"]["Content-Security-Policy"] = (
- "default-src 'self'; "
- "script-src 'self' 'unsafe-inline'; "
- "style-src 'self' 'unsafe-inline'; "
- "img-src * data:; "
- "frame-ancestors 'none'; "
- "form-action 'self'; "
- "base-uri 'self'"
- )
- return resp
diff --git a/handlers/__init__.py b/handlers/__init__.py
new file mode 100644
index 0000000..95d1753
--- /dev/null
+++ b/handlers/__init__.py
@@ -0,0 +1,200 @@
+import json
+import secrets
+import threading
+from urllib.parse import unquote
+
+from db import get_db, return_db, set_setting
+import templates as templates_mod
+from templates import esc, wrap_page
+from rns_client import fetch_remote_sites
+
+from ._helpers import (
+ _request_local, _get_csrf_token, _csrf_field, _check_csrf,
+ _sanitize_fts_query, _get_bookmark_token,
+ _respond, _redirect, _json_response, _text_response, _error,
+ PER_PAGE, BROWSE_PER_PAGE, _paginate, _page_nav,
+ _get_page_tags, _set_page_tags, _cleanup_orphaned_tags,
+)
+from .search import handle_search
+from .pages import (
+ handle_add_form, handle_add_submit, handle_add_manual_submit,
+ handle_pages, _render_bulk_delete_confirm, handle_bulk_action,
+ handle_edit_form, handle_edit_submit,
+ handle_delete_confirm, handle_delete,
+ handle_bookmark,
+)
+from .subscriptions import (
+ _page_is_shared, _shared_sites, _shared_all_urls, _count_shared_pages,
+ handle_share_preview, handle_api_sites,
+ handle_subscriptions, handle_subscription_add, handle_subscription_browse,
+ handle_subscription_pick, _sync_subscription,
+ handle_subscription_sync, handle_subscription_autosync,
+ handle_subscription_delete, handle_subscription_syncall,
+ _sync_threads,
+)
+from .customize import handle_style_form, handle_style_submit, handle_about
+from .tags import handle_tags, handle_tag_browse
+from .data import (
+ handle_export, handle_import_form, handle_import_submit,
+ handle_reindex_form, handle_reindex_submit, _reindex_thread,
+)
+
+forum_plugin = None
+
+
+def _dispatch_inner(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":
+ action_type = query.get("type", ["index"])[0]
+ prefill_url = query.get("url", [""])[0].strip()
+ return handle_add_form(
+ action_type=action_type if action_type == "subscribe" else "index",
+ prefill_url=prefill_url,
+ )
+ elif path == "/pages":
+ return handle_pages(query)
+ 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_confirm(pid) if pid is not None else _error(400)
+ elif path == "/bookmark":
+ return handle_bookmark(query)
+ elif path == "/style":
+ return handle_style_form()
+ elif path == "/share/preview":
+ return handle_share_preview()
+ elif path == "/about":
+ return handle_about()
+ elif path == "/export":
+ return handle_export(query)
+ elif path == "/import":
+ return handle_import_form()
+ elif path == "/tags":
+ return handle_tags()
+ elif path.startswith("/tags/"):
+ tag_name = unquote(path[len("/tags/"):])
+ return handle_tag_browse(tag_name, query) if tag_name else _error(400)
+ elif path == "/reindex":
+ return handle_reindex_form()
+ elif path == "/api/sites":
+ return handle_api_sites(query)
+ 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 path.startswith("/forum"):
+ if forum_plugin and forum_plugin.is_enabled():
+ return forum_plugin.handle(method, path, query, {}, data.get("cookies", {}))
+ return _error(404)
+ elif method == "POST":
+ if path.startswith("/forum"):
+ if forum_plugin and forum_plugin.is_enabled():
+ return forum_plugin.handle(method, path, query, body, data.get("cookies", {}))
+ return _error(404)
+ if not _check_csrf(body):
+ return _respond("403 Forbidden Invalid or missing CSRF token.
", status=403)
+ if path == "/add":
+ return handle_add_submit(body)
+ elif path == "/pages/bulk":
+ return handle_bulk_action(body)
+ elif path == "/add/manual":
+ return handle_add_manual_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.startswith("/delete/"):
+ pid = extract_id("/delete/")
+ return handle_delete(pid) if pid is not None else _error(400)
+ elif path == "/style":
+ return handle_style_submit(body)
+ elif path == "/style/reset":
+ set_setting("custom_template", "")
+ return handle_style_form("Template reset to default.")
+ elif path == "/style/vacuum":
+ from db import vacuum_db
+ vacuum_db()
+ return handle_style_form("Database vacuumed.")
+ elif path == "/import":
+ return handle_import_submit(body)
+ elif path == "/reindex":
+ return handle_reindex_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)
+
+
+def dispatch_request(data):
+ path = data.get("path", "/")
+ cookies = data.get("cookies", {})
+
+ if path.startswith("/forum") and forum_plugin and forum_plugin.is_enabled():
+ resp = _dispatch_inner(data)
+ resp.setdefault("headers", {})
+ resp["headers"]["X-Frame-Options"] = "DENY"
+ resp["headers"]["X-Content-Type-Options"] = "nosniff"
+ if resp.get("content_type", "").startswith("text/html"):
+ resp["body"] = wrap_page(resp.get("body", ""))
+ resp["headers"]["Content-Security-Policy"] = (
+ "default-src 'self'; "
+ "script-src 'self' 'unsafe-inline'; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src * data:; "
+ "frame-ancestors 'none'; "
+ "form-action 'self'; "
+ "base-uri 'self'"
+ )
+ return resp
+
+ csrf_token = cookies.get("_csrf", "")
+ if not csrf_token:
+ csrf_token = secrets.token_hex(32)
+ _request_local.csrf_token = csrf_token
+
+ resp = _dispatch_inner(data)
+
+ resp.setdefault("headers", {})
+ resp["headers"]["Set-Cookie"] = f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/"
+ resp["headers"]["X-Frame-Options"] = "DENY"
+ resp["headers"]["X-Content-Type-Options"] = "nosniff"
+ if resp.get("content_type", "").startswith("text/html"):
+ resp["headers"]["Content-Security-Policy"] = (
+ "default-src 'self'; "
+ "script-src 'self' 'unsafe-inline'; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src * data:; "
+ "frame-ancestors 'none'; "
+ "form-action 'self'; "
+ "base-uri 'self'"
+ )
+ return resp
diff --git a/handlers/_helpers.py b/handlers/_helpers.py
new file mode 100644
index 0000000..3fecb71
--- /dev/null
+++ b/handlers/_helpers.py
@@ -0,0 +1,167 @@
+import json
+import re
+import secrets
+import threading
+
+from db import get_db, return_db, get_setting, set_setting
+from templates import wrap_page
+
+
+_request_local = threading.local()
+
+
+def _get_csrf_token():
+ return getattr(_request_local, 'csrf_token', '')
+
+
+def _csrf_field():
+ return f' '
+
+
+def _check_csrf(body):
+ token = body.get("_csrf", [""])[0]
+ expected = _get_csrf_token()
+ if not expected or not token:
+ return False
+ return secrets.compare_digest(token, expected)
+
+
+_STOPWORDS = frozenset({
+ "a", "an", "the", "and", "or", "but", "is", "are", "was", "were",
+ "in", "on", "at", "to", "for", "of", "with", "by", "from", "as",
+ "into", "about", "how", "what", "which", "who", "where", "when",
+ "do", "does", "did", "be", "been", "being", "have", "has", "had",
+ "it", "its", "this", "that", "not", "no", "so", "if", "can", "will",
+ "my", "your", "i", "me", "we", "you", "he", "she", "they",
+})
+
+
+def _sanitize_fts_query(query):
+ words = query.split()
+ if not words:
+ return '""'
+ tokens = []
+ last_idx = len(words) - 1
+ for i, w in enumerate(words):
+ cleaned = re.sub(r'["\'\(\)\*\+\-\^~:]', '', w).strip()
+ if not cleaned:
+ continue
+ if cleaned.lower() in _STOPWORDS:
+ continue
+ if cleaned.upper() in ("AND", "OR", "NOT", "NEAR"):
+ continue
+ if i == last_idx:
+ tokens.append(f"{cleaned}*")
+ else:
+ tokens.append(f'"{cleaned}"')
+ return " ".join(tokens) if tokens else '""'
+
+
+def _get_bookmark_token():
+ token = get_setting("bookmark_token")
+ if not token:
+ token = secrets.token_hex(16)
+ set_setting("bookmark_token", token)
+ return token
+
+
+def _respond(body_html, status=200, use_default=False):
+ return {
+ "status": status,
+ "content_type": "text/html; charset=utf-8",
+ "body": wrap_page(body_html, use_default=use_default),
+ "headers": {},
+ }
+
+
+def _redirect(location):
+ if not location.startswith("/") or location.startswith("//"):
+ 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"{status} ", status)
+
+
+PER_PAGE = 10
+BROWSE_PER_PAGE = 50
+
+
+def _paginate(query, key="p"):
+ try:
+ page = int(query.get(key, ["1"])[0])
+ except (ValueError, IndexError):
+ page = 1
+ return max(1, page)
+
+
+def _page_nav(page, total, base_url, per_page=None):
+ per_page = per_page or PER_PAGE
+ if total <= per_page:
+ return ""
+ total_pages = (total + per_page - 1) // per_page
+ sep = "&" if "?" in base_url else "?"
+ parts = []
+ if page > 1:
+ parts.append(f'« prev ')
+ parts.append(f"page {page} of {total_pages}")
+ if page < total_pages:
+ parts.append(f'next » ')
+ return f''
+
+
+def _get_page_tags(page_id, db=None):
+ close = False
+ if db is None:
+ db = get_db()
+ close = True
+ rows = db.execute(
+ "SELECT t.name FROM tags t JOIN page_tags pt ON t.id = pt.tag_id "
+ "WHERE pt.page_id = ? ORDER BY t.name", (page_id,)
+ ).fetchall()
+ if close:
+ return_db(db)
+ return [r["name"] for r in rows]
+
+
+def _set_page_tags(page_id, tag_string, db=None):
+ close = False
+ if db is None:
+ db = get_db()
+ close = True
+ db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
+ for name in (t.strip().lower() for t in tag_string.split(",") if t.strip()):
+ db.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
+ tag_id = db.execute("SELECT id FROM tags WHERE name = ?", (name,)).fetchone()["id"]
+ db.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
+ if close:
+ db.commit()
+ return_db(db)
+
+
+def _cleanup_orphaned_tags(db):
+ db.execute("DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM page_tags)")
diff --git a/handlers/customize.py b/handlers/customize.py
new file mode 100644
index 0000000..1f2568b
--- /dev/null
+++ b/handlers/customize.py
@@ -0,0 +1,270 @@
+from db import get_db, return_db, get_setting, set_setting, get_site_name
+import templates as templates_mod
+from templates import esc, DEFAULT_TEMPLATE
+from ._helpers import _respond, _csrf_field, _get_bookmark_token
+from .subscriptions import _count_shared_pages
+
+
+def handle_style_form(msg=""):
+ template = get_setting("custom_template") or DEFAULT_TEMPLATE
+ name = get_site_name()
+ sharing = get_setting("sharing_enabled", "0")
+ checked = " checked" if sharing == "1" else ""
+ sharing_mode = get_setting("sharing_mode", "exclude_private")
+ forum = get_setting("forum_enabled", "0")
+ forum_checked = " checked" if forum == "1" else ""
+ exclude_checked = " checked" if sharing_mode != "require_public" else ""
+ require_checked = " checked" if sharing_mode == "require_public" else ""
+ shared_count = _count_shared_pages()
+ semantic = get_setting("semantic_search", "0")
+ semantic_checked = " checked" if semantic == "1" else ""
+ reranker = get_setting("use_reranker", "0")
+ reranker_checked = " checked" if reranker == "1" else ""
+ disabled = "" if semantic == "1" else " disabled"
+ dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
+ tcp_enabled = get_setting("tcp_enabled", "1")
+ tcp_checked = " checked" if tcp_enabled == "1" else ""
+ tcp_disabled = "" if tcp_enabled == "1" else " disabled"
+ transport_host = get_setting("transport_host", "rnode.bre.land")
+ transport_port = get_setting("transport_port", "4242")
+ compress = get_setting("compress_embeddings", "0")
+ compress_checked = " checked" if compress == "1" else ""
+ lora_enabled = get_setting("lora_enabled", "0")
+ lora_checked = " checked" if lora_enabled == "1" else ""
+ lora_disabled = "" if lora_enabled == "1" else " disabled"
+ lora_dimmed = ' style="opacity:0.4"' if lora_enabled != "1" else ""
+ lora_port = get_setting("lora_port", "")
+ lora_frequency = get_setting("lora_frequency", "867200000")
+ lora_bandwidth = get_setting("lora_bandwidth", "125000")
+ lora_txpower = get_setting("lora_txpower", "7")
+ lora_sf = get_setting("lora_sf", "8")
+ lora_cr = get_setting("lora_cr", "5")
+ from handlers import forum_plugin as _fp
+ if _fp is not None:
+ forum_section = (
+ f"forum "
+ f' '
+ f" enable forum (shared URL discussion board) "
+ f"Share URLs and discuss them with other TinyWeb instances. "
+ f"Requires tinyweb-forum — "
+ f'more info . '
+ )
+ else:
+ forum_section = ""
+ return _respond(
+ f"customize "
+ f"name your search engine "
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f"sharing "
+ f' '
+ f" share your site list publicly at /api/sites "
+ f''
+ f"What to share: "
+ f' '
+ f' share all pages except those tagged private '
+ f' '
+ f' share only pages tagged public '
+ f'The private tag always excludes a page, even in public-only mode. '
+ f'
'
+ f''
+ f'Currently sharing {shared_count} page(s). '
+ f'preview what subscribers would see '
+ f'
'
+ f"mesh network "
+ f"Choose how to connect to the mesh. You can enable both for maximum reach.
"
+ f"internet "
+ f' '
+ f" connect via internet transport node "
+ f"Reach peers anywhere online. "
+ f' '
+ f"LoRa "
+ f' '
+ f" connect via LoRa radio "
+ f"Reach nearby peers off-grid with an RNode . "
+ f''
+ f'Serial port: '
+ f'advanced radio settings '
+ f'
'
+ f"search "
+ f"ai "
+ f' '
+ f" semantic search (similarity matching) "
+ f"Requires onnxruntime, tokenizers, hnswlib. Downloads ~30MB of models on first use. "
+ f'"
+ f"{forum_section}"
+ f"custom html "
+ f"Edit the full page template. Use {esc('{{content}}')} "
+ f"where page content should appear.
"
+ f'{esc(template)} '
+ f'save '
+ f" "
+ f"bookmarklet "
+ f"Drag this link to your bookmarks bar. Click it on any page to index it instantly.
"
+ f'+ save to {esc(name)}
'
+ f"reset "
+ f''
+ f'{_csrf_field()}'
+ f'reset template to default '
+ f" "
+ f"maintenance "
+ f''
+ f'{_csrf_field()}'
+ f'vacuum database '
+ f" "
+ f"{msg}
"
+ f'back ',
+ use_default=True,
+ )
+
+
+def handle_style_submit(body):
+ template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
+ name = body.get("site_name", ["tinyweb"])[0].strip()
+ sharing = "1" if body.get("sharing_enabled") else "0"
+ sharing_mode = body.get("sharing_mode", ["exclude_private"])[0]
+ if sharing_mode not in ("exclude_private", "require_public"):
+ sharing_mode = "exclude_private"
+ set_setting("sharing_mode", sharing_mode)
+ semantic = "1" if body.get("semantic_search") else "0"
+ reranker = "1" if body.get("use_reranker") else "0"
+ compress = "1" if body.get("compress_embeddings") else "0"
+ tcp_enabled = "1" if body.get("tcp_enabled") else "0"
+ transport_host = body.get("transport_host", [""])[0].strip()
+ transport_port = body.get("transport_port", [""])[0].strip()
+ set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
+ set_setting("site_name", name or "tinyweb")
+ set_setting("sharing_enabled", sharing)
+ set_setting("semantic_search", semantic)
+ set_setting("use_reranker", reranker)
+ set_setting("compress_embeddings", compress)
+ set_setting("tcp_enabled", tcp_enabled)
+ if transport_host:
+ set_setting("transport_host", transport_host)
+ if transport_port:
+ set_setting("transport_port", transport_port)
+ lora_enabled = "1" if body.get("lora_enabled") else "0"
+ set_setting("lora_enabled", lora_enabled)
+ set_setting("lora_port", body.get("lora_port", [""])[0].strip())
+ set_setting("lora_frequency", body.get("lora_frequency", ["867200000"])[0].strip())
+ set_setting("lora_bandwidth", body.get("lora_bandwidth", ["125000"])[0].strip())
+ set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
+ set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
+ set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
+ forum_enabled = "1" if body.get("forum_enabled") else "0"
+ current_forum = get_setting("forum_enabled", "0")
+ if forum_enabled != current_forum:
+ from handlers import forum_plugin
+ if forum_enabled == "1" and forum_plugin is None:
+ return handle_style_form(
+ "Forum plugin not installed. Run: pip install tinyweb-forum"
+ )
+ if forum_enabled == "1":
+ forum_plugin.enable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "1")
+ except Exception:
+ pass
+ else:
+ forum_plugin.disable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "0")
+ except Exception:
+ pass
+ set_setting("forum_enabled", forum_enabled)
+ templates_mod.FORUM_ENABLED = (forum_enabled == "1")
+ return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.")
+
+
+def handle_about():
+ name = get_site_name()
+ dest_hash = get_setting("dest_hash")
+ sharing = get_setting("sharing_enabled", "0") == "1"
+ db = get_db()
+ try:
+ page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
+ tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0]
+ sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0]
+ finally:
+ return_db(db)
+
+ sharing_html = (
+ 'This instance shares its index publicly. Subscribe to join the network.
'
+ if sharing else
+ 'This instance is private.
'
+ )
+
+ hash_html = ""
+ if dest_hash:
+ hash_html = (
+ f'subscribe '
+ f'To subscribe to this instance, add this destination hash in your TinyWeb:
'
+ f'{esc(dest_hash)} '
+ )
+
+ return _respond(
+ f'{esc(name)} '
+ f'A personal, decentralized search engine.
'
+ f'You save pages you find. They are stored locally and shared over a mesh network '
+ f'so other people can find them too.
'
+ f'Search results come from your index and the indexes of people you are connected to.
'
+ f''
+ f'{page_count} page(s) indexed '
+ f'{tag_count} tag(s) '
+ f'{sub_count} subscription(s) '
+ f' '
+ f'{sharing_html}'
+ f'{hash_html}'
+ f'your data '
+ f'Everything is stored locally under ~/.tinyweb/:
'
+ f''
+ f'tinyweb_identity — your permanent mesh identity. '
+ f'If you lose this file, your destination hash changes and subscribers '
+ f'have to re-subscribe to the new one. '
+ f'index.db — your full reading history: every page, '
+ f'note, tag, and synced remote page. '
+ f'models/ — the semantic search model if you enabled it '
+ f'(redownloadable, safe to delete). '
+ f' '
+ f'Back up ~/.tinyweb/ periodically. '
+ f'Copying the whole directory to another device preserves your identity and index together. '
+ f'The export page gives you a JSON dump of pages only — '
+ f'it does not preserve your identity or subscription state, so it is a migration aid, '
+ f'not a substitute for a full backup.
'
+ f'search | browse | tags
'
+ )
diff --git a/handlers/data.py b/handlers/data.py
new file mode 100644
index 0000000..d3a714f
--- /dev/null
+++ b/handlers/data.py
@@ -0,0 +1,125 @@
+import json
+import threading
+
+from db import get_db, return_db, get_setting, set_setting, index_url
+from templates import esc
+from ._helpers import _respond, _json_response, _redirect, _csrf_field
+
+MAX_EXPORT = 10000
+_reindex_thread = None
+
+
+def handle_export(query=None):
+ try:
+ batch = int((query or {}).get("batch", ["0"])[0])
+ except (TypeError, ValueError):
+ batch = 0
+ db = get_db()
+ try:
+ rows = db.execute(
+ "SELECT url, title, note FROM pages ORDER BY id LIMIT ? OFFSET ?",
+ (MAX_EXPORT, batch * MAX_EXPORT),
+ ).fetchall()
+ finally:
+ return_db(db)
+ 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"import "
+ f"Paste the contents of a tinyweb export file (JSON).
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f'import '
+ 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.")
+
+ MAX_IMPORT = 100
+ if len(data) > MAX_IMPORT:
+ return handle_import_form(f"Too many entries. Maximum is {MAX_IMPORT}.")
+
+ 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_reindex_form():
+ if get_setting("semantic_search", "0") != "1":
+ return _respond(
+ f"semantic search index "
+ f"Semantic search is disabled. Enable it in settings to use embeddings.
"
+ f'back to search
'
+ )
+ db = get_db()
+ try:
+ total_pages = db.execute("SELECT count(*) FROM pages").fetchone()[0]
+ pages_with_chunks = db.execute(
+ "SELECT count(DISTINCT page_id) FROM chunks WHERE page_id IS NOT NULL"
+ ).fetchone()[0]
+ finally:
+ return_db(db)
+ progress = get_setting("reindex_progress", "")
+ status_html = ""
+ if progress:
+ status_html = f'Reindex in progress: {esc(progress)}
'
+ elif _reindex_thread and _reindex_thread.is_alive():
+ status_html = 'Reindex running...
'
+ return _respond(
+ f"semantic search index "
+ f"{pages_with_chunks} of {total_pages} pages have embeddings.
"
+ f'{status_html}'
+ f''
+ f'{_csrf_field()}'
+ f'reindex all pages '
+ f' '
+ f'back to search
'
+ )
+
+
+def handle_reindex_submit(body):
+ global _reindex_thread
+ if _reindex_thread and _reindex_thread.is_alive():
+ return handle_reindex_form()
+
+ def _run():
+ try:
+ from embeddings import reindex_all
+ def progress(current, total):
+ set_setting("reindex_progress", f"{current}/{total}")
+ reindex_all(progress_callback=progress)
+ except Exception:
+ pass
+ finally:
+ set_setting("reindex_progress", "")
+
+ _reindex_thread = threading.Thread(target=_run, daemon=True)
+ _reindex_thread.start()
+ return _redirect("/reindex")
diff --git a/handlers/pages.py b/handlers/pages.py
new file mode 100644
index 0000000..3e213e0
--- /dev/null
+++ b/handlers/pages.py
@@ -0,0 +1,387 @@
+from pathlib import Path
+import json
+import secrets
+from urllib.parse import unquote
+
+from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
+from templates import esc
+from ._helpers import (
+ _csrf_field, _respond, _redirect, _error,
+ _paginate, _page_nav, _get_page_tags, _set_page_tags, _cleanup_orphaned_tags,
+ _get_bookmark_token, _text_response,
+ BROWSE_PER_PAGE,
+)
+
+
+def handle_add_form(msg="", action_type="index", prefill_url=""):
+ if action_type == "subscribe":
+ return _respond(
+ f"subscribe "
+ f"Subscribe to a friend's TinyWeb instance to sync their index
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f'subscribe '
+ f" "
+ f"or add a single site
"
+ f"{msg}
"
+ f'back '
+ )
+ url_value = f'value="{esc(prefill_url)}" ' if prefill_url else ""
+ return _respond(
+ f"add url "
+ f"Add a site to your index
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f' '
+ f' '
+ f'tag: private to exclude from sharing '
+ f'index '
+ f" "
+ f"{msg}
"
+ f'back '
+ )
+
+
+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(">", "")
+ 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}"
+
+ try:
+ title = index_url(url, note, reticulum_dest if reticulum_dest else "")
+ if tags:
+ db = get_db()
+ try:
+ row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
+ if row:
+ _set_page_tags(row["id"], tags, db)
+ db.commit()
+ finally:
+ return_db(db)
+
+ return handle_add_form(f'Indexed: {esc(url)}')
+
+ except ValueError as e:
+ return handle_add_form(f"Error: {esc(str(e))}")
+
+ except Exception as e:
+ error_msg = str(e).lower()
+ if "block" in error_msg or "cloudflare" in error_msg or "403" in error_msg:
+ return _respond(
+ f"add url (manual entry) "
+ f"{esc(url)} blocks automated access. "
+ f"You can still save it manually:
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f' '
+ f' '
+ f'Title: '
+ f' '
+ f'Description: '
+ f' '
+ f'save manually '
+ f" "
+ f'back '
+ )
+ return handle_add_form(f"Error: could not fetch or index that URL. {esc(str(e)[:100])}")
+
+
+def handle_add_manual_submit(body):
+ url = clean_url(body.get("url", [""])[0].strip())
+ note = body.get("note", [""])[0].strip()
+ tags = body.get("tags", [""])[0].strip()
+ manual_title = body.get("manual_title", [""])[0].strip()
+ manual_desc = body.get("manual_description", [""])[0].strip()
+
+ if not url:
+ return handle_add_form("URL is required.")
+
+ if not manual_title:
+ return handle_add_form("Title is required for manual entry.")
+
+ db = get_db()
+ try:
+ now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+
+ db.execute(
+ "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
+ (url, manual_title, manual_desc, note, now, manual_desc[:200]),
+ )
+
+ page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
+
+ if tags:
+ _set_page_tags(page_id, tags, db)
+
+ db.commit()
+
+ if get_setting("semantic_search", "0") == "1":
+ try:
+ from embeddings import store_embeddings
+ store_embeddings(page_id, manual_title, manual_desc, db)
+ db.commit()
+ except Exception as e:
+ print(f"Error generating embeddings: {e}")
+
+ return handle_add_form(f'Added manually: {esc(manual_title)} ')
+ finally:
+ return_db(db)
+
+
+def handle_pages(query=None):
+ msg = query.get("msg", [""])[0] if query else ""
+ msg_html = f'{esc(msg)}
' if msg else ""
+ page = _paginate(query or {})
+ offset = (page - 1) * BROWSE_PER_PAGE
+ db = get_db()
+ try:
+ total = db.execute("SELECT count(*) FROM pages").fetchone()[0]
+ rows = db.execute(
+ "SELECT id, url, title, note FROM pages ORDER BY id DESC LIMIT ? OFFSET ?",
+ (BROWSE_PER_PAGE, offset),
+ ).fetchall()
+ items = ""
+ for r in rows:
+ note_html = f' — {esc(r["note"])} ' if r["note"] else ""
+ tags = _get_page_tags(r["id"], db)
+ tags_html = ""
+ if tags:
+ tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
+ tags_html = f' {tag_links}'
+ items += (
+ f' '
+ f'{esc(r["title"])} {note_html}{tags_html} '
+ f'({esc(r["url"])} ) '
+ f'edit '
+ f'remove '
+ )
+ finally:
+ return_db(db)
+ return _respond(
+ f"indexed pages ({total}) "
+ f"{msg_html}"
+ f''
+ f'{_csrf_field()}'
+ f' select all
'
+ f""
+ f'{_page_nav(page, total, "/pages", BROWSE_PER_PAGE)}'
+ f'bulk actions '
+ f'delete selected
'
+ f' '
+ f'add tags replace tags '
+ f'retag selected
'
+ f' '
+ f' '
+ f''
+ f'export | import
'
+ f'back '
+ )
+
+
+def _render_bulk_delete_confirm(page_ids):
+ db = get_db()
+ try:
+ placeholders = ",".join("?" * len(page_ids))
+ rows = db.execute(
+ f"SELECT id, url, title FROM pages WHERE id IN ({placeholders})",
+ page_ids,
+ ).fetchall()
+ finally:
+ return_db(db)
+ if not rows:
+ return _redirect("/pages")
+ items = "".join(
+ f'{esc(r["title"] or r["url"])} '
+ f'{esc(r["url"])} '
+ for r in rows
+ )
+ hidden_ids = "".join(
+ f' ' for r in rows
+ )
+ n = len(rows)
+ return _respond(
+ f"confirm delete "
+ f"Remove the following {n} page{'' if n == 1 else 's'}?
"
+ f""
+ f''
+ f'{_csrf_field()}'
+ f'{hidden_ids}'
+ f' '
+ f' '
+ f'yes, delete {n} page{"" if n == 1 else "s"} '
+ f" "
+ f' cancel '
+ )
+
+
+def handle_bulk_action(body):
+ ids = body.get("ids", [])
+ action = body.get("action", [""])[0]
+ if not ids:
+ return _redirect("/pages")
+ try:
+ page_ids = [int(i) for i in ids]
+ except ValueError:
+ return _error(400)
+ if action == "delete" and body.get("confirmed", [""])[0] != "1":
+ return _render_bulk_delete_confirm(page_ids)
+ db = get_db()
+ try:
+ if action == "delete":
+ for pid in page_ids:
+ db.execute("DELETE FROM page_tags WHERE page_id = ?", (pid,))
+ db.execute("DELETE FROM links WHERE page_id = ?", (pid,))
+ db.execute("DELETE FROM pages WHERE id = ?", (pid,))
+ _cleanup_orphaned_tags(db)
+ db.commit()
+ elif action == "retag":
+ bulk_tags = body.get("bulk_tags", [""])[0].strip()
+ tag_mode = body.get("tag_mode", ["add"])[0]
+ if bulk_tags:
+ for pid in page_ids:
+ if tag_mode == "add":
+ existing = _get_page_tags(pid, db)
+ new_tags = [t.strip().lower() for t in bulk_tags.split(",") if t.strip()]
+ merged = ", ".join(sorted(set(existing + new_tags)))
+ _set_page_tags(pid, merged, db)
+ else:
+ _set_page_tags(pid, bulk_tags, db)
+ _cleanup_orphaned_tags(db)
+ db.commit()
+ finally:
+ return_db(db)
+ return _redirect("/pages")
+
+
+def handle_edit_form(page_id, msg=""):
+ db = get_db()
+ try:
+ row = db.execute("SELECT id, url, title, body, note, summary FROM pages WHERE id = ?", (page_id,)).fetchone()
+ if not row:
+ return _error(404)
+ tags = ", ".join(_get_page_tags(page_id, db))
+ finally:
+ return_db(db)
+
+ return _respond(
+ f"edit page "
+ f"{esc(row['title'])} "
+ f"{esc(row['url'])}
"
+ f''
+ f'{_csrf_field()}'
+ f'Title: '
+ f' '
+ f'Summary (shown in search results): '
+ f'{esc(row["summary"] or "")} '
+ f'Note (why you saved this): '
+ f' '
+ f'Tags (comma-separated): '
+ f' '
+ f'(tag: private to keep private) '
+ f'save '
+ f" "
+ f"{msg}
"
+ f'back '
+ )
+
+
+def handle_edit_submit(page_id, body):
+ title = body.get("title", [""])[0].strip()
+ summary = body.get("summary", [""])[0].strip()
+ note = body.get("note", [""])[0].strip()
+ tags = body.get("tags", [""])[0].strip()
+
+ db = get_db()
+ try:
+ db.execute(
+ "UPDATE pages SET title = ?, summary = ?, note = ? WHERE id = ?",
+ (title, summary, note, page_id)
+ )
+
+ _set_page_tags(page_id, tags, db)
+ _cleanup_orphaned_tags(db)
+
+ db.commit()
+
+ finally:
+ return_db(db)
+
+ return _redirect("/pages")
+
+
+def handle_delete_confirm(page_id):
+ db = get_db()
+ try:
+ row = db.execute("SELECT id, url, title FROM pages WHERE id = ?", (page_id,)).fetchone()
+ finally:
+ return_db(db)
+ if not row:
+ return _error(404)
+ return _respond(
+ f"confirm delete "
+ f"Remove {esc(row['title'])} "
+ f"{esc(row['url'])}
"
+ f''
+ f'{_csrf_field()}'
+ f'yes, delete '
+ f" "
+ f' cancel '
+ )
+
+
+def handle_delete(page_id):
+ db = get_db()
+ try:
+ db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
+ db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
+ db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
+ _cleanup_orphaned_tags(db)
+ db.commit()
+ finally:
+ return_db(db)
+ return _redirect("/pages")
+
+
+def handle_bookmark(query):
+ token = query.get("token", [""])[0]
+ expected = _get_bookmark_token()
+ if not token or not secrets.compare_digest(token, expected):
+ return _text_response("error: invalid or missing token", status=403, headers={"Access-Control-Allow-Origin": "*"})
+ url = clean_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": "*"})
diff --git a/handlers/search.py b/handlers/search.py
new file mode 100644
index 0000000..967b59c
--- /dev/null
+++ b/handlers/search.py
@@ -0,0 +1,171 @@
+from db import get_db, return_db, get_setting, get_site_name, clean_url
+from templates import esc
+from ._helpers import _sanitize_fts_query, _paginate, _get_page_tags, _respond, _page_nav, PER_PAGE
+
+
+def handle_search(query):
+ q = query.get("q", [""])[0].strip()
+ page = _paginate(query)
+ offset = (page - 1) * PER_PAGE
+ db = get_db()
+ try:
+ count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
+ name = get_site_name()
+
+ result_html = ""
+ trusted_html = ""
+ if q:
+ try:
+ fts_q = _sanitize_fts_query(q)
+ bm25_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 bm25(pages_fts, 10.0, 1.0, 5.0, 3.0) LIMIT 100",
+ (fts_q,),
+ ).fetchall()
+ except Exception:
+ bm25_rows = []
+
+ bm25_ids = [r["id"] for r in bm25_rows]
+ chunk_snippets = {}
+ if get_setting("semantic_search", "0") == "1":
+ try:
+ from embeddings import hybrid_search
+ use_reranker = get_setting("use_reranker", "1") == "1"
+ fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
+ fused_ids = [pid for pid, _ in fused]
+ chunk_snippets = {pid: text for pid, text in fused if text}
+ except Exception:
+ fused_ids = bm25_ids
+ else:
+ fused_ids = bm25_ids
+
+ total_results = len(fused_ids)
+ page_ids = fused_ids[offset:offset + PER_PAGE]
+
+ if page_ids:
+ placeholders = ",".join("?" * len(page_ids))
+ all_rows = db.execute(
+ f"SELECT id, url, title, body, note, summary FROM pages WHERE id IN ({placeholders})",
+ page_ids,
+ ).fetchall()
+ row_map = {r["id"]: r for r in all_rows}
+ rows = [row_map[pid] for pid in page_ids if pid in row_map]
+ else:
+ rows = []
+
+ if rows:
+ for r in rows:
+ note_html = ""
+ if r["note"]:
+ note_html = f'{esc(r["note"])}
'
+ tags = _get_page_tags(r["id"], db)
+ tags_html = ""
+ if tags:
+ tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
+ tags_html = f'{tag_links}
'
+ snip_html = f' {esc(r["summary"])}' if r["summary"] else ""
+ result_html += (
+ f''
+ f'
{esc(r["title"])} '
+ f'
{esc(r["url"])} '
+ f'{snip_html}'
+ f'{note_html}{tags_html}'
+ f'
'
+ )
+ else:
+ result_html = "No results in your index.
"
+
+ 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'{esc(l["label"])} '
+ f'— from {esc(l["source_title"])} '
+ )
+ trusted_html = (
+ f''
+ f'from your trusted sites ({len(trusted)}) '
+ f''
+ f' '
+ )
+
+ try:
+ 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",
+ (_sanitize_fts_query(q),),
+ ).fetchall()
+ except Exception:
+ remote_rows = []
+
+ remote_html = ""
+ if q and remote_rows:
+ 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' — {esc(r["note"])} ' if r["note"] else ""
+ source_items += (
+ f'{esc(r["title"])} '
+ f'{note_html} ({esc(clean_url(r["url"]))}) '
+ )
+ remote_html += (
+ f''
+ f'from {esc(source)} ({len(items)}) '
+ f''
+ f' '
+ )
+ finally:
+ return_db(db)
+ 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 = (
+ ''
+ )
+ return _respond(
+ f''
+ f' '
+ f' search '
+ f' '
+ f'{count} pages indexed'
+ f' · + add url
'
+ f'{welcome_html}'
+ f'{result_html}'
+ f'{_page_nav(page, total_results, f"/?q={esc(q)}") if q else ""}'
+ f'{trusted_html}{remote_html}'
+ )
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
new file mode 100644
index 0000000..b54a253
--- /dev/null
+++ b/handlers/subscriptions.py
@@ -0,0 +1,455 @@
+import threading
+from datetime import datetime
+
+from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
+from templates import esc
+from rns_client import fetch_remote_sites
+from ._helpers import (
+ _get_page_tags, _respond, _redirect, _json_response, _error,
+ _csrf_field,
+)
+
+_sync_threads = {}
+
+MAX_API_SITES = 5000
+MAX_BROWSE = 5000
+
+
+def _page_is_shared(tags, mode):
+ if "private" in tags:
+ return False
+ if mode == "require_public" and "public" not in tags:
+ return False
+ return True
+
+
+def _shared_sites(db, since=""):
+ mode = get_setting("sharing_mode", "exclude_private")
+ if since:
+ rows = db.execute(
+ "SELECT id, url, title, note, last_modified FROM pages "
+ "WHERE last_modified > ? ORDER BY id DESC LIMIT ?",
+ (since, MAX_API_SITES),
+ ).fetchall()
+ else:
+ rows = db.execute(
+ "SELECT id, url, title, note, last_modified FROM pages ORDER BY id DESC LIMIT ?",
+ (MAX_API_SITES,),
+ ).fetchall()
+ sites = []
+ for r in rows:
+ tags = _get_page_tags(r["id"], db)
+ if not _page_is_shared(tags, mode):
+ continue
+ sites.append({
+ "url": r["url"], "title": r["title"], "note": r["note"],
+ "tags": tags, "last_modified": r["last_modified"] or "",
+ })
+ return sites
+
+
+def _shared_all_urls(db):
+ mode = get_setting("sharing_mode", "exclude_private")
+ rows = db.execute(
+ "SELECT id, url FROM pages ORDER BY id DESC LIMIT ?", (MAX_API_SITES,)
+ ).fetchall()
+ return [r["url"] for r in rows if _page_is_shared(_get_page_tags(r["id"], db), mode)]
+
+
+def _count_shared_pages():
+ db = get_db()
+ try:
+ return len(_shared_all_urls(db))
+ finally:
+ return_db(db)
+
+
+def handle_share_preview():
+ mode = get_setting("sharing_mode", "exclude_private")
+ mode_label = (
+ "only pages tagged public"
+ if mode == "require_public"
+ else "all pages except those tagged private"
+ )
+ sharing_on = get_setting("sharing_enabled", "0") == "1"
+ status = (
+ 'Sharing is enabled . Subscribers see the pages listed below.
'
+ if sharing_on else
+ 'Sharing is disabled . Nothing is actually being shared right now; '
+ 'this is the list that would be exposed if you enabled it.
'
+ )
+ db = get_db()
+ try:
+ sites = _shared_sites(db)
+ finally:
+ return_db(db)
+ if not sites:
+ body = (
+ "sharing preview "
+ f"Rule: {mode_label}.
"
+ f"{status}"
+ "No pages match the current rule.
"
+ 'back to settings
'
+ )
+ return _respond(body)
+ rows = ""
+ for s in sites:
+ tags_html = ""
+ if s["tags"]:
+ tags_html = " " + " ".join(f"[{esc(t)}]" for t in s["tags"])
+ note_html = f' — {esc(s["note"])} ' if s["note"] else ""
+ rows += (
+ f''
+ f'{esc(s["title"] or s["url"])} '
+ f'{note_html}{tags_html} '
+ f'{esc(s["url"])} '
+ f' '
+ )
+ body = (
+ "sharing preview "
+ f"Rule: {mode_label}.
"
+ f"{status}"
+ f"{len(sites)} page(s) visible to subscribers.
"
+ f""
+ 'back to settings
'
+ )
+ return _respond(body)
+
+
+def handle_api_sites(query=None):
+ if get_setting("sharing_enabled", "0") != "1":
+ return _json_response(
+ {"error": "sharing disabled"},
+ status=403,
+ headers={"Access-Control-Allow-Origin": "*"},
+ )
+ since = (query or {}).get("since", [""])[0].strip()
+ db = get_db()
+ try:
+ sites = _shared_sites(db, since=since)
+ all_urls = _shared_all_urls(db) if not since else None
+ finally:
+ return_db(db)
+ data = {"name": get_site_name(), "sites": sites}
+ if all_urls is not None:
+ data["all_urls"] = all_urls
+ return _json_response(data, headers={"Access-Control-Allow-Origin": "*"})
+
+
+def handle_subscriptions(msg=""):
+ db = get_db()
+ try:
+ subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
+ finally:
+ return_db(db)
+ cards = ""
+ for s in subs:
+ sub_id = s["id"]
+ auto_label = "on" if s["auto_sync"] else "off"
+ last = s["last_sync"] or "never"
+ sync_status = get_setting(f"sync_status_{sub_id}", "")
+ is_syncing = sub_id in _sync_threads and _sync_threads[sub_id].is_alive()
+
+ if is_syncing:
+ status_html = 'syncing...
'
+ elif sync_status.startswith("error:"):
+ err_msg = sync_status[6:]
+ status_html = f'{esc(err_msg)}
'
+ else:
+ status_html = ""
+
+ if is_syncing:
+ sync_btn = 'syncing... '
+ else:
+ sync_btn = (
+ f''
+ f'{_csrf_field()}sync now '
+ )
+
+ cards += (
+ f''
+ f'
{esc(s["name"] or "unknown")}
'
+ f'
{esc(s["dest_hash"])}
'
+ f'
last sync: {esc(last)}
'
+ f'{status_html}'
+ f'
'
+ f'
browse '
+ f'{sync_btn}'
+ f'
'
+ f'{_csrf_field()}auto-sync: {auto_label} '
+ f'
'
+ f'{_csrf_field()}remove '
+ f'
'
+ f'
'
+ )
+ listing = ""
+ if subs:
+ any_syncing = any(sid in _sync_threads and _sync_threads[sid].is_alive() for sid in [s["id"] for s in subs])
+ syncall_btn = 'syncing... ' if any_syncing else 'sync all '
+ listing = (
+ f'{cards}'
+ f''
+ f'{_csrf_field()}{syncall_btn} '
+ )
+ return _respond(
+ f"subscriptions "
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f'subscribe '
+ f' '
+ f'or subscribe to an instance
'
+ f'{msg}
'
+ f' {listing}'
+ f'back '
+ )
+
+
+def handle_subscription_add(body):
+ 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:
+ 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:
+ return handle_subscriptions("Could not reach that instance.")
+ db = get_db()
+ try:
+ db.execute(
+ "INSERT INTO subscriptions (dest_hash, name) VALUES (?, ?) "
+ "ON CONFLICT(dest_hash) DO UPDATE SET name=excluded.name",
+ (dest_hash, name),
+ )
+ db.commit()
+ finally:
+ return_db(db)
+ return handle_subscriptions(f"Subscribed to {esc(name or dest_hash)}.")
+
+
+def handle_subscription_browse(sub_id):
+ db = get_db()
+ try:
+ sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
+ if not sub:
+ return _error(404)
+ local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages LIMIT ?", (MAX_BROWSE,)).fetchall())
+
+ remote_rows = db.execute(
+ "SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ? LIMIT ?",
+ (sub_id, MAX_BROWSE),
+ ).fetchall()
+ finally:
+ return_db(db)
+
+ if remote_rows:
+ sites = []
+ for r in remote_rows:
+ tags = [t for t in r["tags"].split(",") if t] if r["tags"] else []
+ sites.append({"url": r["url"], "title": r["title"], "note": r["note"], "tags": tags})
+ else:
+ try:
+ data = fetch_remote_sites(sub["dest_hash"])
+ sites = data.get("sites", [])
+ except PermissionError:
+ return handle_subscriptions("That instance has sharing disabled.")
+ except Exception:
+ return handle_subscriptions("Could not fetch sites from that instance.")
+
+ new_items = ""
+ existing_items = ""
+ new_count = 0
+ for s in sites:
+ if s["url"] in local_urls:
+ existing_items += (
+ f'{esc(s["title"])} '
+ f'({esc(s["url"])}) — already indexed '
+ )
+ else:
+ new_count += 1
+ note_html = f' — {esc(s["note"])} ' if s.get("note") else ""
+ tags_html = ""
+ if s.get("tags"):
+ tags_html = " " + " ".join(f'[{esc(t)}]' for t in s["tags"])
+ new_items += (
+ f' '
+ f' {esc(s["title"])}{note_html}{tags_html}'
+ f' ({esc(s["url"])}) '
+ )
+
+ buttons = ""
+ if new_count:
+ buttons = 'import selected import all new '
+ return _respond(
+ f'browsing: {esc(sub["name"] or sub["dest_hash"])} '
+ f'{len(sites)} site(s) available, {new_count} new
'
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f''
+ f'{buttons}'
+ f' '
+ f'already indexed '
+ f'back '
+ )
+
+
+def handle_subscription_pick(body):
+ sub_id = body.get("sub_id", [""])[0]
+ import_all = body.get("import_all", [""])[0]
+
+ db = get_db()
+ try:
+ remote_rows = db.execute(
+ "SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,)
+ ).fetchall()
+ remote_tags = {r["url"]: r["tags"] for r in remote_rows}
+
+ if import_all:
+ local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages LIMIT ?", (MAX_BROWSE,)).fetchall())
+ urls = [r["url"] for r in remote_rows if r["url"] not in local_urls]
+ else:
+ urls = body.get("urls", [])
+ finally:
+ return_db(db)
+
+ if not urls:
+ return handle_subscriptions("No sites selected.")
+
+ imported = 0
+ errors = 0
+ for url in urls:
+ try:
+ index_url(url)
+ tags_str = remote_tags.get(url, "")
+ if tags_str:
+ db = get_db()
+ try:
+ row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
+ if row:
+ _set_page_tags(row["id"], tags_str, db)
+ db.commit()
+ finally:
+ return_db(db)
+ imported += 1
+ except Exception:
+ errors += 1
+ return handle_subscriptions(f"Imported {imported} page(s). {errors} error(s).")
+
+
+def _sync_subscription(sub_id):
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ db = get_db()
+ try:
+ sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
+ if not sub:
+ set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
+ return
+ since = sub["last_sync"].replace(" ", "T") if sub["last_sync"] else ""
+ try:
+ data = fetch_remote_sites(sub["dest_hash"], since=since)
+ sites = data.get("sites", [])
+ all_urls = data.get("all_urls")
+ remote_name = data.get("name", sub["name"])
+ except PermissionError:
+ set_setting(f"sync_status_{sub_id}", "error:That instance has sharing disabled.")
+ return
+ except Exception as e:
+ set_setting(f"sync_status_{sub_id}", f"error:Could not sync \u2014 {e}")
+ return
+
+ if all_urls is not None:
+ existing = db.execute(
+ "SELECT id, url FROM remote_pages WHERE subscription_id = ?", (sub_id,)
+ ).fetchall()
+ remote_url_set = set(all_urls)
+ for row in existing:
+ if row["url"] not in remote_url_set:
+ db.execute("DELETE FROM remote_pages WHERE id = ?", (row["id"],))
+
+ synced = 0
+ for s in sites:
+ try:
+ tags_str = ",".join(s.get("tags", []))
+ db.execute(
+ "INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?) "
+ "ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
+ (sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
+ )
+ if get_setting("semantic_search", "0") == "1":
+ try:
+ from embeddings import store_remote_embeddings
+ rp_id = db.execute(
+ "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
+ (sub_id, s["url"]),
+ ).fetchone()["id"]
+ store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
+ except Exception:
+ pass
+ synced += 1
+ except Exception:
+ pass
+ now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+ db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
+ db.commit()
+ set_setting(f"sync_status_{sub_id}", f"done:{synced}")
+ except Exception as e:
+ set_setting(f"sync_status_{sub_id}", f"error:{e}")
+ finally:
+ return_db(db)
+
+
+def handle_subscription_sync(sub_id):
+ if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
+ return _redirect("/subscriptions")
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
+ _sync_threads[sub_id] = t
+ t.start()
+ return _redirect("/subscriptions")
+
+
+def handle_subscription_autosync(sub_id):
+ db = get_db()
+ try:
+ db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,))
+ db.commit()
+ finally:
+ return_db(db)
+ return _redirect("/subscriptions")
+
+
+def handle_subscription_delete(sub_id):
+ db = get_db()
+ try:
+ db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
+ db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,))
+ db.commit()
+ finally:
+ return_db(db)
+ return _redirect("/subscriptions")
+
+
+def handle_subscription_syncall():
+ db = get_db()
+ try:
+ subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall()
+ finally:
+ return_db(db)
+ if not subs:
+ return handle_subscriptions("No subscriptions have auto-sync enabled.")
+ for sub in subs:
+ sub_id = sub["id"]
+ if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
+ continue
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
+ _sync_threads[sub_id] = t
+ t.start()
+ return _redirect("/subscriptions")
diff --git a/handlers/tags.py b/handlers/tags.py
new file mode 100644
index 0000000..f0e927d
--- /dev/null
+++ b/handlers/tags.py
@@ -0,0 +1,59 @@
+from db import get_db, return_db
+from templates import esc
+from ._helpers import _respond, _paginate, _page_nav, _get_page_tags, BROWSE_PER_PAGE
+
+
+def handle_tags():
+ db = get_db()
+ try:
+ rows = db.execute(
+ "SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t "
+ "JOIN page_tags pt ON t.id = pt.tag_id "
+ "GROUP BY t.id ORDER BY t.name"
+ ).fetchall()
+ finally:
+ return_db(db)
+ items = ""
+ for r in rows:
+ items += f'{esc(r["name"])} ({r["cnt"]}) '
+ return _respond(
+ f"tags "
+ f"" if items else "No tags yet. Add tags when saving or editing pages.
"
+ f'back '
+ )
+
+
+def handle_tag_browse(tag_name, query=None):
+ page = _paginate(query or {})
+ offset = (page - 1) * BROWSE_PER_PAGE
+ db = get_db()
+ try:
+ total = db.execute(
+ "SELECT count(*) FROM page_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ?",
+ (tag_name,),
+ ).fetchone()[0]
+ rows = db.execute(
+ "SELECT p.id, p.url, p.title, p.note FROM pages p "
+ "JOIN page_tags pt ON p.id = pt.page_id "
+ "JOIN tags t ON t.id = pt.tag_id "
+ "WHERE t.name = ? ORDER BY p.id DESC LIMIT ? OFFSET ?",
+ (tag_name, BROWSE_PER_PAGE, offset),
+ ).fetchall()
+ items = ""
+ for r in rows:
+ note_html = f' — {esc(r["note"])} ' if r["note"] else ""
+ tags = _get_page_tags(r["id"], db)
+ tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
+ items += (
+ f'{esc(r["title"])}{note_html} {tag_links} '
+ f'({esc(r["url"])} ) '
+ )
+ finally:
+ return_db(db)
+ return _respond(
+ f'tag: {esc(tag_name)} '
+ f'{total} page(s)
'
+ f''
+ f'{_page_nav(page, total, f"/tags/{esc(tag_name)}", BROWSE_PER_PAGE)}'
+ f'all tags | back '
+ )
From dce16e313e0cb9b11406f66a0efe60c11479af8d Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 02:34:04 +0000
Subject: [PATCH 140/194] split handlers.py into handlers/ package
- _helpers.py: CSRF, FTS sanitizer, pagination, response helpers, tag helpers
- search.py: BM25 + hybrid search, trusted/remote result rendering
- pages.py: add/edit/delete/bulk/bookmark handlers
- subscriptions.py: sync, share preview, API sites, subscription CRUD
- customize.py: settings form, about page
- tags.py: tag list and tag browse handlers
- data.py: export, import, semantic reindex handlers
- __init__.py: dispatch, re-exports, forum_plugin, _request_local
All 58 external symbols re-exported. No changes to app.py, conftest.py,
or any test file.
---
handlers.py | 1822 -------------------------------------
handlers/__init__.py | 200 ++++
handlers/_helpers.py | 167 ++++
handlers/customize.py | 270 ++++++
handlers/data.py | 125 +++
handlers/pages.py | 387 ++++++++
handlers/search.py | 171 ++++
handlers/subscriptions.py | 455 +++++++++
handlers/tags.py | 59 ++
9 files changed, 1834 insertions(+), 1822 deletions(-)
delete mode 100644 handlers.py
create mode 100644 handlers/__init__.py
create mode 100644 handlers/_helpers.py
create mode 100644 handlers/customize.py
create mode 100644 handlers/data.py
create mode 100644 handlers/pages.py
create mode 100644 handlers/search.py
create mode 100644 handlers/subscriptions.py
create mode 100644 handlers/tags.py
diff --git a/handlers.py b/handlers.py
deleted file mode 100644
index 2ece135..0000000
--- a/handlers.py
+++ /dev/null
@@ -1,1822 +0,0 @@
-import json
-import re
-import secrets
-import threading
-from datetime import datetime
-from urllib.parse import unquote
-
-from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
-import templates as templates_mod
-from templates import esc, wrap_page, DEFAULT_TEMPLATE
-from rns_client import fetch_remote_sites
-
-forum_plugin = None
-_request_local = threading.local()
-
-
-def _get_csrf_token():
- return getattr(_request_local, 'csrf_token', '')
-
-
-def _csrf_field():
- return f' '
-
-
-def _check_csrf(body):
- token = body.get("_csrf", [""])[0]
- expected = _get_csrf_token()
- if not expected or not token:
- return False
- return secrets.compare_digest(token, expected)
-
-
-_STOPWORDS = frozenset({
- "a", "an", "the", "and", "or", "but", "is", "are", "was", "were",
- "in", "on", "at", "to", "for", "of", "with", "by", "from", "as",
- "into", "about", "how", "what", "which", "who", "where", "when",
- "do", "does", "did", "be", "been", "being", "have", "has", "had",
- "it", "its", "this", "that", "not", "no", "so", "if", "can", "will",
- "my", "your", "i", "me", "we", "you", "he", "she", "they",
-})
-
-
-def _sanitize_fts_query(query):
- """Escape user input for safe use in FTS5 MATCH.
-
- Splits into individual quoted tokens joined by implicit AND,
- so all words must appear but in any order. Appends * to the
- last token for prefix matching. Stopwords are dropped to avoid
- overly strict matching.
- """
- words = query.split()
- if not words:
- return '""'
- tokens = []
- last_idx = len(words) - 1
- for i, w in enumerate(words):
- # Strip FTS5 special characters (operators, column filter colon) to prevent injection
- cleaned = re.sub(r'["\'\(\)\*\+\-\^~:]', '', w).strip()
- if not cleaned:
- continue
- if cleaned.lower() in _STOPWORDS:
- continue
- # Drop FTS5 operator words so they aren't parsed as operators on the unquoted last token
- if cleaned.upper() in ("AND", "OR", "NOT", "NEAR"):
- continue
- if i == last_idx:
- # Prefix match on the last token for partial word matching
- tokens.append(f"{cleaned}*")
- else:
- tokens.append(f'"{cleaned}"')
- return " ".join(tokens) if tokens else '""'
-
-
-def _get_bookmark_token():
- token = get_setting("bookmark_token")
- if not token:
- token = secrets.token_hex(16)
- set_setting("bookmark_token", token)
- return token
-
-
-def _respond(body_html, status=200, use_default=False):
- return {
- "status": status,
- "content_type": "text/html; charset=utf-8",
- "body": wrap_page(body_html, use_default=use_default),
- "headers": {},
- }
-
-
-def _redirect(location):
- if not location.startswith("/") or location.startswith("//"):
- 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"{status} ", status)
-
-
-PER_PAGE = 10
-BROWSE_PER_PAGE = 50
-
-
-def _paginate(query, key="p"):
- try:
- page = int(query.get(key, ["1"])[0])
- except (ValueError, IndexError):
- page = 1
- return max(1, page)
-
-
-def _page_nav(page, total, base_url, per_page=None):
- per_page = per_page or PER_PAGE
- if total <= per_page:
- return ""
- total_pages = (total + per_page - 1) // per_page
- sep = "&" if "?" in base_url else "?"
- parts = []
- if page > 1:
- parts.append(f'« prev ')
- parts.append(f"page {page} of {total_pages}")
- if page < total_pages:
- parts.append(f'next » ')
- return f''
-
-
-# --- Tag helpers ---
-
-
-def _get_page_tags(page_id, db=None):
- close = False
- if db is None:
- db = get_db()
- close = True
- rows = db.execute(
- "SELECT t.name FROM tags t JOIN page_tags pt ON t.id = pt.tag_id "
- "WHERE pt.page_id = ? ORDER BY t.name", (page_id,)
- ).fetchall()
- if close:
- return_db(db)
- return [r["name"] for r in rows]
-
-
-def _set_page_tags(page_id, tag_string, db=None):
- close = False
- if db is None:
- db = get_db()
- close = True
- db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
- for name in (t.strip().lower() for t in tag_string.split(",") if t.strip()):
- db.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
- tag_id = db.execute("SELECT id FROM tags WHERE name = ?", (name,)).fetchone()["id"]
- db.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
- if close:
- db.commit()
- return_db(db)
-
-
-def _cleanup_orphaned_tags(db):
- """Delete tags that have no page associations."""
- db.execute("DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM page_tags)")
-
-
-# --- Route handlers ---
-
-
-def handle_search(query):
- q = query.get("q", [""])[0].strip()
- page = _paginate(query)
- offset = (page - 1) * PER_PAGE
- db = get_db()
- try:
- count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
- name = get_site_name()
-
- result_html = ""
- trusted_html = ""
- if q:
- # BM25 keyword search with column weights: title=10, body=1, url=5, note=3
- try:
- fts_q = _sanitize_fts_query(q)
- bm25_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 bm25(pages_fts, 10.0, 1.0, 5.0, 3.0) LIMIT 100",
- (fts_q,),
- ).fetchall()
- except Exception:
- bm25_rows = []
-
- # Hybrid search: merge BM25 + semantic via RRF
- bm25_ids = [r["id"] for r in bm25_rows]
- chunk_snippets = {} # page_id -> best chunk text
- if get_setting("semantic_search", "0") == "1":
- try:
- from embeddings import hybrid_search
- use_reranker = get_setting("use_reranker", "1") == "1"
- fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
- fused_ids = [pid for pid, _ in fused]
- chunk_snippets = {pid: text for pid, text in fused if text}
- except Exception:
- fused_ids = bm25_ids
- else:
- fused_ids = bm25_ids
-
- total_results = len(fused_ids)
- page_ids = fused_ids[offset:offset + PER_PAGE]
-
- if page_ids:
- # Fetch rows in fused order
- placeholders = ",".join("?" * len(page_ids))
- all_rows = db.execute(
- f"SELECT id, url, title, body, note, summary FROM pages WHERE id IN ({placeholders})",
- page_ids,
- ).fetchall()
- row_map = {r["id"]: r for r in all_rows}
- rows = [row_map[pid] for pid in page_ids if pid in row_map]
- else:
- rows = []
-
- if rows:
- for r in rows:
- note_html = ""
- if r["note"]:
- note_html = f'{esc(r["note"])}
'
- tags = _get_page_tags(r["id"], db)
- tags_html = ""
- if tags:
- tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
- tags_html = f'{tag_links}
'
- snip_html = f' {esc(r["summary"])}' if r["summary"] else ""
- result_html += (
- f''
- f'
{esc(r["title"])} '
- f'
{esc(r["url"])} '
- f'{snip_html}'
- f'{note_html}{tags_html}'
- f'
'
- )
- else:
- result_html = "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'{esc(l["label"])} '
- f'— from {esc(l["source_title"])} '
- )
- trusted_html = (
- f''
- f'from your trusted sites ({len(trusted)}) '
- f''
- f' '
- )
-
- # search synced pages from subscriptions
- try:
- 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",
- (_sanitize_fts_query(q),),
- ).fetchall()
- except Exception:
- remote_rows = []
-
- 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' — {esc(r["note"])} ' if r["note"] else ""
- source_items += (
- f'{esc(r["title"])} '
- f'{note_html} ({esc(clean_url(r["url"]))}) '
- )
- remote_html += (
- f''
- f'from {esc(source)} ({len(items)}) '
- f''
- f' '
- )
- finally:
- return_db(db)
- 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 = (
- ''
- )
- return _respond(
- f''
- f' '
- f' search '
- f' '
- f'{count} pages indexed'
- f' · + add url
'
- f'{welcome_html}'
- f'{result_html}'
- f'{_page_nav(page, total_results, f"/?q={esc(q)}") if q else ""}'
- f'{trusted_html}{remote_html}'
- )
-
-
-def handle_add_form(msg="", action_type="index", prefill_url=""):
- if action_type == "subscribe":
- return _respond(
- f"subscribe "
- f"Subscribe to a friend's TinyWeb instance to sync their index
"
- f''
- f'{_csrf_field()}'
- f' '
- f'subscribe '
- f" "
- f"or add a single site
"
- f"{msg}
"
- f'back '
- )
- url_value = f'value="{esc(prefill_url)}" ' if prefill_url else ""
- return _respond(
- f"add url "
- f"Add a site to your index
"
- f''
- f'{_csrf_field()}'
- f' '
- f' '
- f' '
- f'tag: private to exclude from sharing '
- f'index '
- f" "
- f"{msg}
"
- f'back '
- )
-
-
-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(">", "")
- 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}"
-
- try:
- title = index_url(url, note, reticulum_dest if reticulum_dest else "")
- if tags:
- db = get_db()
- try:
- row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
- if row:
- _set_page_tags(row["id"], tags, db)
- db.commit()
- finally:
- return_db(db)
-
- return handle_add_form(f'Indexed: {esc(url)}')
-
- except ValueError as e:
- return handle_add_form(f"Error: {esc(str(e))}")
-
- except Exception as e:
- error_msg = str(e).lower()
- # Check if it's a block response
- if "block" in error_msg or "cloudflare" in error_msg or "403" in error_msg:
- # Show manual entry form for blocked sites
- return _respond(
- f"add url (manual entry) "
- f"{esc(url)} blocks automated access. "
- f"You can still save it manually:
"
- f''
- f'{_csrf_field()}'
- f' '
- f' '
- f' '
- f'Title: '
- f' '
- f'Description: '
- f' '
- f'save manually '
- f" "
- f'back '
- )
- return handle_add_form(f"Error: could not fetch or index that URL. {esc(str(e)[:100])}")
-
-
-def handle_add_manual_submit(body):
- url = clean_url(body.get("url", [""])[0].strip())
- note = body.get("note", [""])[0].strip()
- tags = body.get("tags", [""])[0].strip()
- manual_title = body.get("manual_title", [""])[0].strip()
- manual_desc = body.get("manual_description", [""])[0].strip()
-
- if not url:
- return handle_add_form("URL is required.")
-
- if not manual_title:
- return handle_add_form("Title is required for manual entry.")
-
- db = get_db()
- try:
- now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
-
- db.execute(
- "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
- "ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
- "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
- (url, manual_title, manual_desc, note, now, manual_desc[:200]),
- )
-
- # Get the page ID
- page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
-
- # Add tags if provided
- if tags:
- _set_page_tags(page_id, tags, db)
-
- db.commit()
-
- # Generate embeddings for this page (if semantic search is enabled)
- if get_setting("semantic_search", "0") == "1":
- try:
- from embeddings import store_embeddings
- # Pass the page_id, title, description, and db connection
- store_embeddings(page_id, manual_title, manual_desc, db)
- db.commit()
- except Exception as e:
- # Log error but don't fail the whole operation
- print(f"Error generating embeddings: {e}")
-
- return handle_add_form(f'Added manually: {esc(manual_title)} ')
- finally:
- return_db(db)
-
-
-def handle_pages(query=None):
- msg = query.get("msg", [""])[0] if query else ""
- msg_html = f'{esc(msg)}
' if msg else ""
- page = _paginate(query or {})
- offset = (page - 1) * BROWSE_PER_PAGE
- db = get_db()
- try:
- total = db.execute("SELECT count(*) FROM pages").fetchone()[0]
- rows = db.execute(
- "SELECT id, url, title, note FROM pages ORDER BY id DESC LIMIT ? OFFSET ?",
- (BROWSE_PER_PAGE, offset),
- ).fetchall()
- items = ""
- for r in rows:
- note_html = f' — {esc(r["note"])} ' if r["note"] else ""
- tags = _get_page_tags(r["id"], db)
- tags_html = ""
- if tags:
- tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
- tags_html = f' {tag_links}'
- items += (
- f' '
- f'{esc(r["title"])} {note_html}{tags_html} '
- f'({esc(r["url"])} ) '
- f'edit '
- f'remove '
- )
- finally:
- return_db(db)
- return _respond(
- f"indexed pages ({total}) "
- f"{msg_html}"
- f''
- f'{_csrf_field()}'
- f' select all
'
- f""
- f'{_page_nav(page, total, "/pages", BROWSE_PER_PAGE)}'
- f'bulk actions '
- f'delete selected
'
- f' '
- f'add tags replace tags '
- f'retag selected
'
- f' '
- f' '
- f''
- f'export | import
'
- f'back '
- )
-
-
-def _render_bulk_delete_confirm(page_ids):
- """Server-side confirmation page for bulk deletion — mirrors handle_delete_confirm."""
- db = get_db()
- try:
- placeholders = ",".join("?" * len(page_ids))
- rows = db.execute(
- f"SELECT id, url, title FROM pages WHERE id IN ({placeholders})",
- page_ids,
- ).fetchall()
- finally:
- return_db(db)
- if not rows:
- return _redirect("/pages")
- items = "".join(
- f'{esc(r["title"] or r["url"])} '
- f'{esc(r["url"])} '
- for r in rows
- )
- hidden_ids = "".join(
- f' ' for r in rows
- )
- n = len(rows)
- return _respond(
- f"confirm delete "
- f"Remove the following {n} page{'' if n == 1 else 's'}?
"
- f""
- f''
- f'{_csrf_field()}'
- f'{hidden_ids}'
- f' '
- f' '
- f'yes, delete {n} page{"" if n == 1 else "s"} '
- f" "
- f' cancel '
- )
-
-
-def handle_bulk_action(body):
- ids = body.get("ids", [])
- action = body.get("action", [""])[0]
- if not ids:
- return _redirect("/pages")
- # Validate all ids are integers
- try:
- page_ids = [int(i) for i in ids]
- except ValueError:
- return _error(400)
- # Require an explicit second-step confirmation for bulk delete — the JS
- # confirm() on /pages is a first-line filter only.
- if action == "delete" and body.get("confirmed", [""])[0] != "1":
- return _render_bulk_delete_confirm(page_ids)
- db = get_db()
- try:
- if action == "delete":
- for pid in page_ids:
- db.execute("DELETE FROM page_tags WHERE page_id = ?", (pid,))
- db.execute("DELETE FROM links WHERE page_id = ?", (pid,))
- db.execute("DELETE FROM pages WHERE id = ?", (pid,))
- _cleanup_orphaned_tags(db)
- db.commit()
- elif action == "retag":
- bulk_tags = body.get("bulk_tags", [""])[0].strip()
- tag_mode = body.get("tag_mode", ["add"])[0]
- if bulk_tags:
- for pid in page_ids:
- if tag_mode == "add":
- existing = _get_page_tags(pid, db)
- new_tags = [t.strip().lower() for t in bulk_tags.split(",") if t.strip()]
- merged = ", ".join(sorted(set(existing + new_tags)))
- _set_page_tags(pid, merged, db)
- else:
- _set_page_tags(pid, bulk_tags, db)
- _cleanup_orphaned_tags(db)
- db.commit()
- finally:
- return_db(db)
- return _redirect("/pages")
-
-
-def handle_edit_form(page_id, msg=""):
- db = get_db()
- try:
- row = db.execute("SELECT id, url, title, body, note, summary FROM pages WHERE id = ?", (page_id,)).fetchone()
- if not row:
- return _error(404)
- tags = ", ".join(_get_page_tags(page_id, db))
- finally:
- return_db(db)
-
- return _respond(
- f"edit page "
- f"{esc(row['title'])} "
- f"{esc(row['url'])}
"
- f''
- f'{_csrf_field()}'
- f'Title: '
- f' '
- f'Summary (shown in search results): '
- f'{esc(row["summary"] or "")} '
- f'Note (why you saved this): '
- f' '
- f'Tags (comma-separated): '
- f' '
- f'(tag: private to keep private) '
- f'save '
- f" "
- f"{msg}
"
- f'back '
- )
-
-
-def handle_edit_submit(page_id, body):
- title = body.get("title", [""])[0].strip()
- summary = body.get("summary", [""])[0].strip()
- note = body.get("note", [""])[0].strip()
- tags = body.get("tags", [""])[0].strip()
-
- db = get_db()
- try:
- db.execute(
- "UPDATE pages SET title = ?, summary = ?, note = ? WHERE id = ?",
- (title, summary, note, page_id)
- )
-
- _set_page_tags(page_id, tags, db)
- _cleanup_orphaned_tags(db)
-
- db.commit()
-
- finally:
- return_db(db)
-
- return _redirect("/pages")
-
-
-def handle_delete_confirm(page_id):
- db = get_db()
- try:
- row = db.execute("SELECT id, url, title FROM pages WHERE id = ?", (page_id,)).fetchone()
- finally:
- return_db(db)
- if not row:
- return _error(404)
- return _respond(
- f"confirm delete "
- f"Remove {esc(row['title'])} "
- f"{esc(row['url'])}
"
- f''
- f'{_csrf_field()}'
- f'yes, delete '
- f" "
- f' cancel '
- )
-
-
-def handle_delete(page_id):
- db = get_db()
- try:
- db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
- db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
- db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
- _cleanup_orphaned_tags(db)
- db.commit()
- finally:
- return_db(db)
- return _redirect("/pages")
-
-
-def handle_bookmark(query):
- token = query.get("token", [""])[0]
- expected = _get_bookmark_token()
- if not token or not secrets.compare_digest(token, expected):
- return _text_response("error: invalid or missing token", status=403, headers={"Access-Control-Allow-Origin": "*"})
- url = clean_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": "*"})
-
-
-MAX_EXPORT = 10000
-
-def handle_export(query=None):
- try:
- batch = int((query or {}).get("batch", ["0"])[0])
- except (TypeError, ValueError):
- batch = 0
- db = get_db()
- try:
- rows = db.execute(
- "SELECT url, title, note FROM pages ORDER BY id LIMIT ? OFFSET ?",
- (MAX_EXPORT, batch * MAX_EXPORT),
- ).fetchall()
- finally:
- return_db(db)
- 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"import "
- f"Paste the contents of a tinyweb export file (JSON).
"
- f''
- f'{_csrf_field()}'
- f' '
- f'import '
- 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.")
-
- MAX_IMPORT = 100
- if len(data) > MAX_IMPORT:
- return handle_import_form(f"Too many entries. Maximum is {MAX_IMPORT}.")
-
- 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=""):
- template = get_setting("custom_template") or DEFAULT_TEMPLATE
- name = get_site_name()
- sharing = get_setting("sharing_enabled", "0")
- checked = " checked" if sharing == "1" else ""
- sharing_mode = get_setting("sharing_mode", "exclude_private")
- forum = get_setting("forum_enabled", "0")
- forum_checked = " checked" if forum == "1" else ""
- exclude_checked = " checked" if sharing_mode != "require_public" else ""
- require_checked = " checked" if sharing_mode == "require_public" else ""
- shared_count = _count_shared_pages()
- semantic = get_setting("semantic_search", "0")
- semantic_checked = " checked" if semantic == "1" else ""
- reranker = get_setting("use_reranker", "0")
- reranker_checked = " checked" if reranker == "1" else ""
- disabled = "" if semantic == "1" else " disabled"
- dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
- tcp_enabled = get_setting("tcp_enabled", "1")
- tcp_checked = " checked" if tcp_enabled == "1" else ""
- tcp_disabled = "" if tcp_enabled == "1" else " disabled"
- transport_host = get_setting("transport_host", "rnode.bre.land")
- transport_port = get_setting("transport_port", "4242")
- compress = get_setting("compress_embeddings", "0")
- compress_checked = " checked" if compress == "1" else ""
- lora_enabled = get_setting("lora_enabled", "0")
- lora_checked = " checked" if lora_enabled == "1" else ""
- lora_disabled = "" if lora_enabled == "1" else " disabled"
- lora_dimmed = ' style="opacity:0.4"' if lora_enabled != "1" else ""
- lora_port = get_setting("lora_port", "")
- lora_frequency = get_setting("lora_frequency", "867200000")
- lora_bandwidth = get_setting("lora_bandwidth", "125000")
- lora_txpower = get_setting("lora_txpower", "7")
- lora_sf = get_setting("lora_sf", "8")
- lora_cr = get_setting("lora_cr", "5")
- if forum_plugin is not None:
- forum_section = (
- f"forum "
- f' '
- f" enable forum (shared URL discussion board) "
- f"Share URLs and discuss them with other TinyWeb instances. "
- f"Requires tinyweb-forum — "
- f'more info . '
- )
- else:
- forum_section = ""
- return _respond(
- f"customize "
- f"name your search engine "
- f''
- f'{_csrf_field()}'
- f' '
- f"sharing "
- f' '
- f" share your site list publicly at /api/sites "
- f''
- f"What to share: "
- f' '
- f' share all pages except those tagged private '
- f' '
- f' share only pages tagged public '
- f'The private tag always excludes a page, even in public-only mode. '
- f'
'
- f''
- f'Currently sharing {shared_count} page(s). '
- f'preview what subscribers would see '
- f'
'
- f"mesh network "
- f"Choose how to connect to the mesh. You can enable both for maximum reach.
"
- f"internet "
- f' '
- f" connect via internet transport node "
- f"Reach peers anywhere online. "
- f' '
- f"LoRa "
- f' '
- f" connect via LoRa radio "
- f"Reach nearby peers off-grid with an RNode . "
- f''
- f'Serial port: '
- f'advanced radio settings '
- f'
'
- f"search "
- f"ai "
- f' '
- f" semantic search (similarity matching) "
- f"Requires onnxruntime, tokenizers, hnswlib. Downloads ~30MB of models on first use. "
- f'"
- f"{forum_section}"
- f"custom html "
- f"Edit the full page template. Use {esc('{{content}}')} "
- f"where page content should appear.
"
- f'{esc(template)} '
- f'save '
- f" "
- f"bookmarklet "
- f"Drag this link to your bookmarks bar. Click it on any page to index it instantly.
"
- f'+ save to {esc(name)}
'
- f"reset "
- f''
- f'{_csrf_field()}'
- f'reset template to default '
- f" "
- f"maintenance "
- f''
- f'{_csrf_field()}'
- f'vacuum database '
- f" "
- f"{msg}
"
- f'back ',
- use_default=True,
- )
-
-
-def handle_style_submit(body):
- template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
- name = body.get("site_name", ["tinyweb"])[0].strip()
- sharing = "1" if body.get("sharing_enabled") else "0"
- sharing_mode = body.get("sharing_mode", ["exclude_private"])[0]
- if sharing_mode not in ("exclude_private", "require_public"):
- sharing_mode = "exclude_private"
- set_setting("sharing_mode", sharing_mode)
- semantic = "1" if body.get("semantic_search") else "0"
- reranker = "1" if body.get("use_reranker") else "0"
- compress = "1" if body.get("compress_embeddings") else "0"
- tcp_enabled = "1" if body.get("tcp_enabled") else "0"
- transport_host = body.get("transport_host", [""])[0].strip()
- transport_port = body.get("transport_port", [""])[0].strip()
- set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
- set_setting("site_name", name or "tinyweb")
- set_setting("sharing_enabled", sharing)
- set_setting("semantic_search", semantic)
- set_setting("use_reranker", reranker)
- set_setting("compress_embeddings", compress)
- set_setting("tcp_enabled", tcp_enabled)
- if transport_host:
- set_setting("transport_host", transport_host)
- if transport_port:
- set_setting("transport_port", transport_port)
- lora_enabled = "1" if body.get("lora_enabled") else "0"
- set_setting("lora_enabled", lora_enabled)
- set_setting("lora_port", body.get("lora_port", [""])[0].strip())
- set_setting("lora_frequency", body.get("lora_frequency", ["867200000"])[0].strip())
- set_setting("lora_bandwidth", body.get("lora_bandwidth", ["125000"])[0].strip())
- set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
- set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
- set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
- forum_enabled = "1" if body.get("forum_enabled") else "0"
- current_forum = get_setting("forum_enabled", "0")
- if forum_enabled != current_forum:
- if forum_enabled == "1" and forum_plugin is None:
- return handle_style_form(
- "Forum plugin not installed. Run: pip install tinyweb-forum"
- )
- if forum_enabled == "1":
- forum_plugin.enable()
- try:
- forum_plugin.fdb.set_setting("forum_enabled", "1")
- except Exception:
- pass
- else:
- forum_plugin.disable()
- try:
- forum_plugin.fdb.set_setting("forum_enabled", "0")
- except Exception:
- pass
- set_setting("forum_enabled", forum_enabled)
- templates_mod.FORUM_ENABLED = (forum_enabled == "1")
- return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.")
-
-
-def handle_about():
- name = get_site_name()
- dest_hash = get_setting("dest_hash")
- sharing = get_setting("sharing_enabled", "0") == "1"
- db = get_db()
- try:
- page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
- tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0]
- sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0]
- finally:
- return_db(db)
-
- sharing_html = (
- 'This instance shares its index publicly. Subscribe to join the network.
'
- if sharing else
- 'This instance is private.
'
- )
-
- hash_html = ""
- if dest_hash:
- hash_html = (
- f'subscribe '
- f'To subscribe to this instance, add this destination hash in your TinyWeb:
'
- f'{esc(dest_hash)} '
- )
-
- return _respond(
- f'{esc(name)} '
- f'A personal, decentralized search engine.
'
- f'You save pages you find. They are stored locally and shared over a mesh network '
- f'so other people can find them too.
'
- f'Search results come from your index and the indexes of people you are connected to.
'
- f''
- f'{page_count} page(s) indexed '
- f'{tag_count} tag(s) '
- f'{sub_count} subscription(s) '
- f' '
- f'{sharing_html}'
- f'{hash_html}'
- f'your data '
- f'Everything is stored locally under ~/.tinyweb/:
'
- f''
- f'tinyweb_identity — your permanent mesh identity. '
- f'If you lose this file, your destination hash changes and subscribers '
- f'have to re-subscribe to the new one. '
- f'index.db — your full reading history: every page, '
- f'note, tag, and synced remote page. '
- f'models/ — the semantic search model if you enabled it '
- f'(redownloadable, safe to delete). '
- f' '
- f'Back up ~/.tinyweb/ periodically. '
- f'Copying the whole directory to another device preserves your identity and index together. '
- f'The export page gives you a JSON dump of pages only — '
- f'it does not preserve your identity or subscription state, so it is a migration aid, '
- f'not a substitute for a full backup.
'
- f'search | browse | tags
'
- )
-
-
-def handle_tags():
- db = get_db()
- try:
- rows = db.execute(
- "SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t "
- "JOIN page_tags pt ON t.id = pt.tag_id "
- "GROUP BY t.id ORDER BY t.name"
- ).fetchall()
- finally:
- return_db(db)
- items = ""
- for r in rows:
- items += f'{esc(r["name"])} ({r["cnt"]}) '
- return _respond(
- f"tags "
- f"" if items else "No tags yet. Add tags when saving or editing pages.
"
- f'back '
- )
-
-
-def handle_tag_browse(tag_name, query=None):
- page = _paginate(query or {})
- offset = (page - 1) * BROWSE_PER_PAGE
- db = get_db()
- try:
- total = db.execute(
- "SELECT count(*) FROM page_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ?",
- (tag_name,),
- ).fetchone()[0]
- rows = db.execute(
- "SELECT p.id, p.url, p.title, p.note FROM pages p "
- "JOIN page_tags pt ON p.id = pt.page_id "
- "JOIN tags t ON t.id = pt.tag_id "
- "WHERE t.name = ? ORDER BY p.id DESC LIMIT ? OFFSET ?",
- (tag_name, BROWSE_PER_PAGE, offset),
- ).fetchall()
- items = ""
- for r in rows:
- note_html = f' — {esc(r["note"])} ' if r["note"] else ""
- tags = _get_page_tags(r["id"], db)
- tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
- items += (
- f'{esc(r["title"])}{note_html} {tag_links} '
- f'({esc(r["url"])} ) '
- )
- finally:
- return_db(db)
- return _respond(
- f'tag: {esc(tag_name)} '
- f'{total} page(s)
'
- f''
- f'{_page_nav(page, total, f"/tags/{esc(tag_name)}", BROWSE_PER_PAGE)}'
- f'all tags | back '
- )
-
-
-MAX_API_SITES = 5000
-
-
-def _page_is_shared(tags, mode):
- """Decide whether a page with the given tags is shared under the given mode.
-
- `private` always wins — a page tagged private is never shared, regardless of mode.
- """
- if "private" in tags:
- return False
- if mode == "require_public" and "public" not in tags:
- return False
- return True
-
-
-def _shared_sites(db, since=""):
- """Return the full site records that a subscriber would receive.
-
- The caller owns the db connection.
- """
- mode = get_setting("sharing_mode", "exclude_private")
- if since:
- rows = db.execute(
- "SELECT id, url, title, note, last_modified FROM pages "
- "WHERE last_modified > ? ORDER BY id DESC LIMIT ?",
- (since, MAX_API_SITES),
- ).fetchall()
- else:
- rows = db.execute(
- "SELECT id, url, title, note, last_modified FROM pages ORDER BY id DESC LIMIT ?",
- (MAX_API_SITES,),
- ).fetchall()
- sites = []
- for r in rows:
- tags = _get_page_tags(r["id"], db)
- if not _page_is_shared(tags, mode):
- continue
- sites.append({
- "url": r["url"], "title": r["title"], "note": r["note"],
- "tags": tags, "last_modified": r["last_modified"] or "",
- })
- return sites
-
-
-def _shared_all_urls(db):
- """Return the URL list a subscriber uses to detect deletions."""
- mode = get_setting("sharing_mode", "exclude_private")
- rows = db.execute(
- "SELECT id, url FROM pages ORDER BY id DESC LIMIT ?", (MAX_API_SITES,)
- ).fetchall()
- return [r["url"] for r in rows if _page_is_shared(_get_page_tags(r["id"], db), mode)]
-
-
-def _count_shared_pages():
- """Cheap page count under the current sharing rule — used by the settings UI."""
- db = get_db()
- try:
- return len(_shared_all_urls(db))
- finally:
- return_db(db)
-
-
-def handle_share_preview():
- """Show the list of pages a subscriber would currently receive.
-
- Works regardless of whether sharing is enabled — lets the user see the surface
- before flipping it on.
- """
- mode = get_setting("sharing_mode", "exclude_private")
- mode_label = (
- "only pages tagged public"
- if mode == "require_public"
- else "all pages except those tagged private"
- )
- sharing_on = get_setting("sharing_enabled", "0") == "1"
- status = (
- 'Sharing is enabled . Subscribers see the pages listed below.
'
- if sharing_on else
- 'Sharing is disabled . Nothing is actually being shared right now; '
- 'this is the list that would be exposed if you enabled it.
'
- )
- db = get_db()
- try:
- sites = _shared_sites(db)
- finally:
- return_db(db)
- if not sites:
- body = (
- "sharing preview "
- f"Rule: {mode_label}.
"
- f"{status}"
- "No pages match the current rule.
"
- 'back to settings
'
- )
- return _respond(body)
- rows = ""
- for s in sites:
- tags_html = ""
- if s["tags"]:
- tags_html = " " + " ".join(f"[{esc(t)}]" for t in s["tags"])
- note_html = f' — {esc(s["note"])} ' if s["note"] else ""
- rows += (
- f''
- f'{esc(s["title"] or s["url"])} '
- f'{note_html}{tags_html} '
- f'{esc(s["url"])} '
- f' '
- )
- body = (
- "sharing preview "
- f"Rule: {mode_label}.
"
- f"{status}"
- f"{len(sites)} page(s) visible to subscribers.
"
- f""
- 'back to settings
'
- )
- return _respond(body)
-
-
-def handle_api_sites(query=None):
- if get_setting("sharing_enabled", "0") != "1":
- return _json_response(
- {"error": "sharing disabled"},
- status=403,
- headers={"Access-Control-Allow-Origin": "*"},
- )
- since = (query or {}).get("since", [""])[0].strip()
- db = get_db()
- try:
- sites = _shared_sites(db, since=since)
- all_urls = _shared_all_urls(db) if not since else None
- finally:
- return_db(db)
- data = {"name": get_site_name(), "sites": sites}
- if all_urls is not None:
- data["all_urls"] = all_urls
- return _json_response(data, headers={"Access-Control-Allow-Origin": "*"})
-
-
-_sync_threads = {}
-
-
-def handle_subscriptions(msg=""):
- db = get_db()
- try:
- subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
- finally:
- return_db(db)
- cards = ""
- for s in subs:
- sub_id = s["id"]
- auto_label = "on" if s["auto_sync"] else "off"
- last = s["last_sync"] or "never"
- sync_status = get_setting(f"sync_status_{sub_id}", "")
- is_syncing = sub_id in _sync_threads and _sync_threads[sub_id].is_alive()
-
- # Status line: show syncing indicator or last result
- if is_syncing:
- status_html = 'syncing...
'
- elif sync_status.startswith("error:"):
- err_msg = sync_status[6:]
- status_html = f'{esc(err_msg)}
'
- else:
- status_html = ""
-
- # Disable sync button while syncing
- if is_syncing:
- sync_btn = 'syncing... '
- else:
- sync_btn = (
- f''
- f'{_csrf_field()}sync now '
- )
-
- cards += (
- f''
- f'
{esc(s["name"] or "unknown")}
'
- f'
{esc(s["dest_hash"])}
'
- f'
last sync: {esc(last)}
'
- f'{status_html}'
- f'
'
- f'
browse '
- f'{sync_btn}'
- f'
'
- f'{_csrf_field()}auto-sync: {auto_label} '
- f'
'
- f'{_csrf_field()}remove '
- f'
'
- f'
'
- )
- listing = ""
- if subs:
- any_syncing = any(sid in _sync_threads and _sync_threads[sid].is_alive() for sid in [s["id"] for s in subs])
- syncall_btn = 'syncing... ' if any_syncing else 'sync all '
- listing = (
- f'{cards}'
- f''
- f'{_csrf_field()}{syncall_btn} '
- )
- return _respond(
- f"subscriptions "
- f''
- f'{_csrf_field()}'
- f' '
- f'subscribe '
- f' '
- f'or subscribe to an instance
'
- f'{msg}
'
- f' {listing}'
- f'back '
- )
-
-
-def handle_subscription_add(body):
- 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:
- 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:
- return handle_subscriptions("Could not reach that instance.")
- db = get_db()
- try:
- db.execute(
- "INSERT INTO subscriptions (dest_hash, name) VALUES (?, ?) "
- "ON CONFLICT(dest_hash) DO UPDATE SET name=excluded.name",
- (dest_hash, name),
- )
- db.commit()
- finally:
- return_db(db)
- return handle_subscriptions(f"Subscribed to {esc(name or dest_hash)}.")
-
-
-MAX_BROWSE = 5000
-
-def handle_subscription_browse(sub_id):
- db = get_db()
- try:
- sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
- if not sub:
- return _error(404)
- local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages LIMIT ?", (MAX_BROWSE,)).fetchall())
-
- # Use locally synced data if available, otherwise fetch live
- remote_rows = db.execute(
- "SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ? LIMIT ?",
- (sub_id, MAX_BROWSE),
- ).fetchall()
- finally:
- return_db(db)
-
- if remote_rows:
- sites = []
- for r in remote_rows:
- tags = [t for t in r["tags"].split(",") if t] if r["tags"] else []
- sites.append({"url": r["url"], "title": r["title"], "note": r["note"], "tags": tags})
- else:
- try:
- data = fetch_remote_sites(sub["dest_hash"])
- sites = data.get("sites", [])
- except PermissionError:
- return handle_subscriptions("That instance has sharing disabled.")
- except Exception:
- return handle_subscriptions("Could not fetch sites from that instance.")
-
- new_items = ""
- existing_items = ""
- new_count = 0
- for s in sites:
- if s["url"] in local_urls:
- existing_items += (
- f'{esc(s["title"])} '
- f'({esc(s["url"])}) — already indexed '
- )
- else:
- new_count += 1
- note_html = f' — {esc(s["note"])} ' if s.get("note") else ""
- tags_html = ""
- if s.get("tags"):
- tags_html = " " + " ".join(f'[{esc(t)}]' for t in s["tags"])
- new_items += (
- f' '
- f' {esc(s["title"])}{note_html}{tags_html}'
- f' ({esc(s["url"])}) '
- )
-
- buttons = ""
- if new_count:
- buttons = 'import selected import all new '
- return _respond(
- f'browsing: {esc(sub["name"] or sub["dest_hash"])} '
- f'{len(sites)} site(s) available, {new_count} new
'
- f''
- f'{_csrf_field()}'
- f' '
- f''
- f'{buttons}'
- f' '
- f'already indexed '
- f'back '
- )
-
-
-def handle_subscription_pick(body):
- sub_id = body.get("sub_id", [""])[0]
- import_all = body.get("import_all", [""])[0]
-
- # Build a url->tags map from remote_pages for this subscription
- db = get_db()
- try:
- remote_rows = db.execute(
- "SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,)
- ).fetchall()
- remote_tags = {r["url"]: r["tags"] for r in remote_rows}
-
- if import_all:
- local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages LIMIT ?", (MAX_BROWSE,)).fetchall())
- urls = [r["url"] for r in remote_rows if r["url"] not in local_urls]
- else:
- urls = body.get("urls", [])
- finally:
- return_db(db)
-
- if not urls:
- return handle_subscriptions("No sites selected.")
-
- imported = 0
- errors = 0
- for url in urls:
- try:
- index_url(url)
- # Import tags from the remote page
- tags_str = remote_tags.get(url, "")
- if tags_str:
- db = get_db()
- try:
- row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
- if row:
- _set_page_tags(row["id"], tags_str, db)
- db.commit()
- finally:
- return_db(db)
- imported += 1
- except Exception:
- errors += 1
- return handle_subscriptions(f"Imported {imported} page(s). {errors} error(s).")
-
-
-def _sync_subscription(sub_id):
- """Run a single subscription sync. Designed to run in a background thread."""
- set_setting(f"sync_status_{sub_id}", "syncing")
- db = get_db()
- try:
- sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
- if not sub:
- set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
- return
- since = sub["last_sync"].replace(" ", "T") if sub["last_sync"] else ""
- try:
- data = fetch_remote_sites(sub["dest_hash"], since=since)
- sites = data.get("sites", [])
- all_urls = data.get("all_urls")
- remote_name = data.get("name", sub["name"])
- except PermissionError:
- set_setting(f"sync_status_{sub_id}", "error:That instance has sharing disabled.")
- return
- except Exception as e:
- set_setting(f"sync_status_{sub_id}", f"error:Could not sync \u2014 {e}")
- return
-
- if all_urls is not None:
- existing = db.execute(
- "SELECT id, url FROM remote_pages WHERE subscription_id = ?", (sub_id,)
- ).fetchall()
- remote_url_set = set(all_urls)
- for row in existing:
- if row["url"] not in remote_url_set:
- db.execute("DELETE FROM remote_pages WHERE id = ?", (row["id"],))
-
- synced = 0
- for s in sites:
- try:
- tags_str = ",".join(s.get("tags", []))
- db.execute(
- "INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?) "
- "ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
- (sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
- )
- if get_setting("semantic_search", "0") == "1":
- try:
- from embeddings import store_remote_embeddings
- rp_id = db.execute(
- "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
- (sub_id, s["url"]),
- ).fetchone()["id"]
- store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
- except Exception:
- pass
- synced += 1
- except Exception:
- pass
- now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
- db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
- db.commit()
- set_setting(f"sync_status_{sub_id}", f"done:{synced}")
- except Exception as e:
- set_setting(f"sync_status_{sub_id}", f"error:{e}")
- finally:
- return_db(db)
-
-
-def handle_subscription_sync(sub_id):
- if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
- return _redirect("/subscriptions")
- # Clear previous status
- set_setting(f"sync_status_{sub_id}", "syncing")
- t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
- _sync_threads[sub_id] = t
- t.start()
- return _redirect("/subscriptions")
-
-
-def handle_subscription_autosync(sub_id):
- db = get_db()
- try:
- db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,))
- db.commit()
- finally:
- return_db(db)
- return _redirect("/subscriptions")
-
-
-def handle_subscription_delete(sub_id):
- db = get_db()
- try:
- db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
- db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,))
- db.commit()
- finally:
- return_db(db)
- return _redirect("/subscriptions")
-
-
-def handle_subscription_syncall():
- db = get_db()
- try:
- subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall()
- finally:
- return_db(db)
- if not subs:
- return handle_subscriptions("No subscriptions have auto-sync enabled.")
- for sub in subs:
- sub_id = sub["id"]
- if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
- continue
- set_setting(f"sync_status_{sub_id}", "syncing")
- t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
- _sync_threads[sub_id] = t
- t.start()
- return _redirect("/subscriptions")
-
-
-# --- Reindex (semantic search) ---
-
-
-_reindex_thread = None
-
-
-def handle_reindex_form():
- if get_setting("semantic_search", "0") != "1":
- return _respond(
- f"semantic search index "
- f"Semantic search is disabled. Enable it in settings to use embeddings.
"
- f'back to search
'
- )
- db = get_db()
- try:
- total_pages = db.execute("SELECT count(*) FROM pages").fetchone()[0]
- pages_with_chunks = db.execute(
- "SELECT count(DISTINCT page_id) FROM chunks WHERE page_id IS NOT NULL"
- ).fetchone()[0]
- finally:
- return_db(db)
- progress = get_setting("reindex_progress", "")
- status_html = ""
- if progress:
- status_html = f'Reindex in progress: {esc(progress)}
'
- elif _reindex_thread and _reindex_thread.is_alive():
- status_html = 'Reindex running...
'
- return _respond(
- f"semantic search index "
- f"{pages_with_chunks} of {total_pages} pages have embeddings.
"
- f'{status_html}'
- f''
- f'{_csrf_field()}'
- f'reindex all pages '
- f' '
- f'back to search
'
- )
-
-
-def handle_reindex_submit(body):
- global _reindex_thread
- if _reindex_thread and _reindex_thread.is_alive():
- return handle_reindex_form()
-
- def _run():
- try:
- from embeddings import reindex_all
- def progress(current, total):
- set_setting("reindex_progress", f"{current}/{total}")
- reindex_all(progress_callback=progress)
- except Exception:
- pass
- finally:
- set_setting("reindex_progress", "")
-
- _reindex_thread = threading.Thread(target=_run, daemon=True)
- _reindex_thread.start()
- return _redirect("/reindex")
-
-
-# --- Dispatcher ---
-
-
-def _dispatch_inner(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":
- action_type = query.get("type", ["index"])[0]
- prefill_url = query.get("url", [""])[0].strip()
- return handle_add_form(
- action_type=action_type if action_type == "subscribe" else "index",
- prefill_url=prefill_url,
- )
- elif path == "/pages":
- return handle_pages(query)
- 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_confirm(pid) if pid is not None else _error(400)
- elif path == "/bookmark":
- return handle_bookmark(query)
- elif path == "/style":
- return handle_style_form()
- elif path == "/share/preview":
- return handle_share_preview()
- elif path == "/about":
- return handle_about()
- elif path == "/export":
- return handle_export(query)
- elif path == "/import":
- return handle_import_form()
- elif path == "/tags":
- return handle_tags()
- elif path.startswith("/tags/"):
- tag_name = unquote(path[len("/tags/"):])
- return handle_tag_browse(tag_name, query) if tag_name else _error(400)
- elif path == "/reindex":
- return handle_reindex_form()
- elif path == "/api/sites":
- return handle_api_sites(query)
- 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 path.startswith("/forum"):
- if forum_plugin and forum_plugin.is_enabled():
- return forum_plugin.handle(method, path, query, {}, data.get("cookies", {}))
- return _error(404)
- elif method == "POST":
- if path.startswith("/forum"):
- if forum_plugin and forum_plugin.is_enabled():
- return forum_plugin.handle(method, path, query, body, data.get("cookies", {}))
- return _error(404)
- if not _check_csrf(body):
- return _respond("403 Forbidden Invalid or missing CSRF token.
", status=403)
- if path == "/add":
- return handle_add_submit(body)
- elif path == "/pages/bulk":
- return handle_bulk_action(body)
- elif path == "/add/manual":
- return handle_add_manual_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.startswith("/delete/"):
- pid = extract_id("/delete/")
- return handle_delete(pid) if pid is not None else _error(400)
- elif path == "/style":
- return handle_style_submit(body)
- elif path == "/style/reset":
- set_setting("custom_template", "")
- return handle_style_form("Template reset to default.")
- elif path == "/style/vacuum":
- from db import vacuum_db
- vacuum_db()
- return handle_style_form("Database vacuumed.")
- elif path == "/import":
- return handle_import_submit(body)
- elif path == "/reindex":
- return handle_reindex_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)
-
-
-def dispatch_request(data):
- path = data.get("path", "/")
- cookies = data.get("cookies", {})
-
- # Forum handles its own CSRF — skip main CSRF to avoid cookie conflicts
- if path.startswith("/forum") and forum_plugin and forum_plugin.is_enabled():
- resp = _dispatch_inner(data)
- resp.setdefault("headers", {})
- resp["headers"]["X-Frame-Options"] = "DENY"
- resp["headers"]["X-Content-Type-Options"] = "nosniff"
- if resp.get("content_type", "").startswith("text/html"):
- resp["body"] = wrap_page(resp.get("body", ""))
- resp["headers"]["Content-Security-Policy"] = (
- "default-src 'self'; "
- "script-src 'self' 'unsafe-inline'; "
- "style-src 'self' 'unsafe-inline'; "
- "img-src * data:; "
- "frame-ancestors 'none'; "
- "form-action 'self'; "
- "base-uri 'self'"
- )
- return resp
-
- csrf_token = cookies.get("_csrf", "")
- if not csrf_token:
- csrf_token = secrets.token_hex(32)
- _request_local.csrf_token = csrf_token
-
- resp = _dispatch_inner(data)
-
- resp.setdefault("headers", {})
- resp["headers"]["Set-Cookie"] = f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/"
- resp["headers"]["X-Frame-Options"] = "DENY"
- resp["headers"]["X-Content-Type-Options"] = "nosniff"
- if resp.get("content_type", "").startswith("text/html"):
- resp["headers"]["Content-Security-Policy"] = (
- "default-src 'self'; "
- "script-src 'self' 'unsafe-inline'; "
- "style-src 'self' 'unsafe-inline'; "
- "img-src * data:; "
- "frame-ancestors 'none'; "
- "form-action 'self'; "
- "base-uri 'self'"
- )
- return resp
diff --git a/handlers/__init__.py b/handlers/__init__.py
new file mode 100644
index 0000000..95d1753
--- /dev/null
+++ b/handlers/__init__.py
@@ -0,0 +1,200 @@
+import json
+import secrets
+import threading
+from urllib.parse import unquote
+
+from db import get_db, return_db, set_setting
+import templates as templates_mod
+from templates import esc, wrap_page
+from rns_client import fetch_remote_sites
+
+from ._helpers import (
+ _request_local, _get_csrf_token, _csrf_field, _check_csrf,
+ _sanitize_fts_query, _get_bookmark_token,
+ _respond, _redirect, _json_response, _text_response, _error,
+ PER_PAGE, BROWSE_PER_PAGE, _paginate, _page_nav,
+ _get_page_tags, _set_page_tags, _cleanup_orphaned_tags,
+)
+from .search import handle_search
+from .pages import (
+ handle_add_form, handle_add_submit, handle_add_manual_submit,
+ handle_pages, _render_bulk_delete_confirm, handle_bulk_action,
+ handle_edit_form, handle_edit_submit,
+ handle_delete_confirm, handle_delete,
+ handle_bookmark,
+)
+from .subscriptions import (
+ _page_is_shared, _shared_sites, _shared_all_urls, _count_shared_pages,
+ handle_share_preview, handle_api_sites,
+ handle_subscriptions, handle_subscription_add, handle_subscription_browse,
+ handle_subscription_pick, _sync_subscription,
+ handle_subscription_sync, handle_subscription_autosync,
+ handle_subscription_delete, handle_subscription_syncall,
+ _sync_threads,
+)
+from .customize import handle_style_form, handle_style_submit, handle_about
+from .tags import handle_tags, handle_tag_browse
+from .data import (
+ handle_export, handle_import_form, handle_import_submit,
+ handle_reindex_form, handle_reindex_submit, _reindex_thread,
+)
+
+forum_plugin = None
+
+
+def _dispatch_inner(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":
+ action_type = query.get("type", ["index"])[0]
+ prefill_url = query.get("url", [""])[0].strip()
+ return handle_add_form(
+ action_type=action_type if action_type == "subscribe" else "index",
+ prefill_url=prefill_url,
+ )
+ elif path == "/pages":
+ return handle_pages(query)
+ 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_confirm(pid) if pid is not None else _error(400)
+ elif path == "/bookmark":
+ return handle_bookmark(query)
+ elif path == "/style":
+ return handle_style_form()
+ elif path == "/share/preview":
+ return handle_share_preview()
+ elif path == "/about":
+ return handle_about()
+ elif path == "/export":
+ return handle_export(query)
+ elif path == "/import":
+ return handle_import_form()
+ elif path == "/tags":
+ return handle_tags()
+ elif path.startswith("/tags/"):
+ tag_name = unquote(path[len("/tags/"):])
+ return handle_tag_browse(tag_name, query) if tag_name else _error(400)
+ elif path == "/reindex":
+ return handle_reindex_form()
+ elif path == "/api/sites":
+ return handle_api_sites(query)
+ 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 path.startswith("/forum"):
+ if forum_plugin and forum_plugin.is_enabled():
+ return forum_plugin.handle(method, path, query, {}, data.get("cookies", {}))
+ return _error(404)
+ elif method == "POST":
+ if path.startswith("/forum"):
+ if forum_plugin and forum_plugin.is_enabled():
+ return forum_plugin.handle(method, path, query, body, data.get("cookies", {}))
+ return _error(404)
+ if not _check_csrf(body):
+ return _respond("403 Forbidden Invalid or missing CSRF token.
", status=403)
+ if path == "/add":
+ return handle_add_submit(body)
+ elif path == "/pages/bulk":
+ return handle_bulk_action(body)
+ elif path == "/add/manual":
+ return handle_add_manual_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.startswith("/delete/"):
+ pid = extract_id("/delete/")
+ return handle_delete(pid) if pid is not None else _error(400)
+ elif path == "/style":
+ return handle_style_submit(body)
+ elif path == "/style/reset":
+ set_setting("custom_template", "")
+ return handle_style_form("Template reset to default.")
+ elif path == "/style/vacuum":
+ from db import vacuum_db
+ vacuum_db()
+ return handle_style_form("Database vacuumed.")
+ elif path == "/import":
+ return handle_import_submit(body)
+ elif path == "/reindex":
+ return handle_reindex_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)
+
+
+def dispatch_request(data):
+ path = data.get("path", "/")
+ cookies = data.get("cookies", {})
+
+ if path.startswith("/forum") and forum_plugin and forum_plugin.is_enabled():
+ resp = _dispatch_inner(data)
+ resp.setdefault("headers", {})
+ resp["headers"]["X-Frame-Options"] = "DENY"
+ resp["headers"]["X-Content-Type-Options"] = "nosniff"
+ if resp.get("content_type", "").startswith("text/html"):
+ resp["body"] = wrap_page(resp.get("body", ""))
+ resp["headers"]["Content-Security-Policy"] = (
+ "default-src 'self'; "
+ "script-src 'self' 'unsafe-inline'; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src * data:; "
+ "frame-ancestors 'none'; "
+ "form-action 'self'; "
+ "base-uri 'self'"
+ )
+ return resp
+
+ csrf_token = cookies.get("_csrf", "")
+ if not csrf_token:
+ csrf_token = secrets.token_hex(32)
+ _request_local.csrf_token = csrf_token
+
+ resp = _dispatch_inner(data)
+
+ resp.setdefault("headers", {})
+ resp["headers"]["Set-Cookie"] = f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/"
+ resp["headers"]["X-Frame-Options"] = "DENY"
+ resp["headers"]["X-Content-Type-Options"] = "nosniff"
+ if resp.get("content_type", "").startswith("text/html"):
+ resp["headers"]["Content-Security-Policy"] = (
+ "default-src 'self'; "
+ "script-src 'self' 'unsafe-inline'; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src * data:; "
+ "frame-ancestors 'none'; "
+ "form-action 'self'; "
+ "base-uri 'self'"
+ )
+ return resp
diff --git a/handlers/_helpers.py b/handlers/_helpers.py
new file mode 100644
index 0000000..3fecb71
--- /dev/null
+++ b/handlers/_helpers.py
@@ -0,0 +1,167 @@
+import json
+import re
+import secrets
+import threading
+
+from db import get_db, return_db, get_setting, set_setting
+from templates import wrap_page
+
+
+_request_local = threading.local()
+
+
+def _get_csrf_token():
+ return getattr(_request_local, 'csrf_token', '')
+
+
+def _csrf_field():
+ return f' '
+
+
+def _check_csrf(body):
+ token = body.get("_csrf", [""])[0]
+ expected = _get_csrf_token()
+ if not expected or not token:
+ return False
+ return secrets.compare_digest(token, expected)
+
+
+_STOPWORDS = frozenset({
+ "a", "an", "the", "and", "or", "but", "is", "are", "was", "were",
+ "in", "on", "at", "to", "for", "of", "with", "by", "from", "as",
+ "into", "about", "how", "what", "which", "who", "where", "when",
+ "do", "does", "did", "be", "been", "being", "have", "has", "had",
+ "it", "its", "this", "that", "not", "no", "so", "if", "can", "will",
+ "my", "your", "i", "me", "we", "you", "he", "she", "they",
+})
+
+
+def _sanitize_fts_query(query):
+ words = query.split()
+ if not words:
+ return '""'
+ tokens = []
+ last_idx = len(words) - 1
+ for i, w in enumerate(words):
+ cleaned = re.sub(r'["\'\(\)\*\+\-\^~:]', '', w).strip()
+ if not cleaned:
+ continue
+ if cleaned.lower() in _STOPWORDS:
+ continue
+ if cleaned.upper() in ("AND", "OR", "NOT", "NEAR"):
+ continue
+ if i == last_idx:
+ tokens.append(f"{cleaned}*")
+ else:
+ tokens.append(f'"{cleaned}"')
+ return " ".join(tokens) if tokens else '""'
+
+
+def _get_bookmark_token():
+ token = get_setting("bookmark_token")
+ if not token:
+ token = secrets.token_hex(16)
+ set_setting("bookmark_token", token)
+ return token
+
+
+def _respond(body_html, status=200, use_default=False):
+ return {
+ "status": status,
+ "content_type": "text/html; charset=utf-8",
+ "body": wrap_page(body_html, use_default=use_default),
+ "headers": {},
+ }
+
+
+def _redirect(location):
+ if not location.startswith("/") or location.startswith("//"):
+ 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"{status} ", status)
+
+
+PER_PAGE = 10
+BROWSE_PER_PAGE = 50
+
+
+def _paginate(query, key="p"):
+ try:
+ page = int(query.get(key, ["1"])[0])
+ except (ValueError, IndexError):
+ page = 1
+ return max(1, page)
+
+
+def _page_nav(page, total, base_url, per_page=None):
+ per_page = per_page or PER_PAGE
+ if total <= per_page:
+ return ""
+ total_pages = (total + per_page - 1) // per_page
+ sep = "&" if "?" in base_url else "?"
+ parts = []
+ if page > 1:
+ parts.append(f'« prev ')
+ parts.append(f"page {page} of {total_pages}")
+ if page < total_pages:
+ parts.append(f'next » ')
+ return f''
+
+
+def _get_page_tags(page_id, db=None):
+ close = False
+ if db is None:
+ db = get_db()
+ close = True
+ rows = db.execute(
+ "SELECT t.name FROM tags t JOIN page_tags pt ON t.id = pt.tag_id "
+ "WHERE pt.page_id = ? ORDER BY t.name", (page_id,)
+ ).fetchall()
+ if close:
+ return_db(db)
+ return [r["name"] for r in rows]
+
+
+def _set_page_tags(page_id, tag_string, db=None):
+ close = False
+ if db is None:
+ db = get_db()
+ close = True
+ db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
+ for name in (t.strip().lower() for t in tag_string.split(",") if t.strip()):
+ db.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
+ tag_id = db.execute("SELECT id FROM tags WHERE name = ?", (name,)).fetchone()["id"]
+ db.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
+ if close:
+ db.commit()
+ return_db(db)
+
+
+def _cleanup_orphaned_tags(db):
+ db.execute("DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM page_tags)")
diff --git a/handlers/customize.py b/handlers/customize.py
new file mode 100644
index 0000000..1f2568b
--- /dev/null
+++ b/handlers/customize.py
@@ -0,0 +1,270 @@
+from db import get_db, return_db, get_setting, set_setting, get_site_name
+import templates as templates_mod
+from templates import esc, DEFAULT_TEMPLATE
+from ._helpers import _respond, _csrf_field, _get_bookmark_token
+from .subscriptions import _count_shared_pages
+
+
+def handle_style_form(msg=""):
+ template = get_setting("custom_template") or DEFAULT_TEMPLATE
+ name = get_site_name()
+ sharing = get_setting("sharing_enabled", "0")
+ checked = " checked" if sharing == "1" else ""
+ sharing_mode = get_setting("sharing_mode", "exclude_private")
+ forum = get_setting("forum_enabled", "0")
+ forum_checked = " checked" if forum == "1" else ""
+ exclude_checked = " checked" if sharing_mode != "require_public" else ""
+ require_checked = " checked" if sharing_mode == "require_public" else ""
+ shared_count = _count_shared_pages()
+ semantic = get_setting("semantic_search", "0")
+ semantic_checked = " checked" if semantic == "1" else ""
+ reranker = get_setting("use_reranker", "0")
+ reranker_checked = " checked" if reranker == "1" else ""
+ disabled = "" if semantic == "1" else " disabled"
+ dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
+ tcp_enabled = get_setting("tcp_enabled", "1")
+ tcp_checked = " checked" if tcp_enabled == "1" else ""
+ tcp_disabled = "" if tcp_enabled == "1" else " disabled"
+ transport_host = get_setting("transport_host", "rnode.bre.land")
+ transport_port = get_setting("transport_port", "4242")
+ compress = get_setting("compress_embeddings", "0")
+ compress_checked = " checked" if compress == "1" else ""
+ lora_enabled = get_setting("lora_enabled", "0")
+ lora_checked = " checked" if lora_enabled == "1" else ""
+ lora_disabled = "" if lora_enabled == "1" else " disabled"
+ lora_dimmed = ' style="opacity:0.4"' if lora_enabled != "1" else ""
+ lora_port = get_setting("lora_port", "")
+ lora_frequency = get_setting("lora_frequency", "867200000")
+ lora_bandwidth = get_setting("lora_bandwidth", "125000")
+ lora_txpower = get_setting("lora_txpower", "7")
+ lora_sf = get_setting("lora_sf", "8")
+ lora_cr = get_setting("lora_cr", "5")
+ from handlers import forum_plugin as _fp
+ if _fp is not None:
+ forum_section = (
+ f"forum "
+ f' '
+ f" enable forum (shared URL discussion board) "
+ f"Share URLs and discuss them with other TinyWeb instances. "
+ f"Requires tinyweb-forum — "
+ f'more info . '
+ )
+ else:
+ forum_section = ""
+ return _respond(
+ f"customize "
+ f"name your search engine "
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f"sharing "
+ f' '
+ f" share your site list publicly at /api/sites "
+ f''
+ f"What to share: "
+ f' '
+ f' share all pages except those tagged private '
+ f' '
+ f' share only pages tagged public '
+ f'The private tag always excludes a page, even in public-only mode. '
+ f'
'
+ f''
+ f'Currently sharing {shared_count} page(s). '
+ f'preview what subscribers would see '
+ f'
'
+ f"mesh network "
+ f"Choose how to connect to the mesh. You can enable both for maximum reach.
"
+ f"internet "
+ f' '
+ f" connect via internet transport node "
+ f"Reach peers anywhere online. "
+ f' '
+ f"LoRa "
+ f' '
+ f" connect via LoRa radio "
+ f"Reach nearby peers off-grid with an RNode . "
+ f''
+ f'Serial port: '
+ f'advanced radio settings '
+ f'
'
+ f"search "
+ f"ai "
+ f' '
+ f" semantic search (similarity matching) "
+ f"Requires onnxruntime, tokenizers, hnswlib. Downloads ~30MB of models on first use. "
+ f'"
+ f"{forum_section}"
+ f"custom html "
+ f"Edit the full page template. Use {esc('{{content}}')} "
+ f"where page content should appear.
"
+ f'{esc(template)} '
+ f'save '
+ f" "
+ f"bookmarklet "
+ f"Drag this link to your bookmarks bar. Click it on any page to index it instantly.
"
+ f'+ save to {esc(name)}
'
+ f"reset "
+ f''
+ f'{_csrf_field()}'
+ f'reset template to default '
+ f" "
+ f"maintenance "
+ f''
+ f'{_csrf_field()}'
+ f'vacuum database '
+ f" "
+ f"{msg}
"
+ f'back ',
+ use_default=True,
+ )
+
+
+def handle_style_submit(body):
+ template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
+ name = body.get("site_name", ["tinyweb"])[0].strip()
+ sharing = "1" if body.get("sharing_enabled") else "0"
+ sharing_mode = body.get("sharing_mode", ["exclude_private"])[0]
+ if sharing_mode not in ("exclude_private", "require_public"):
+ sharing_mode = "exclude_private"
+ set_setting("sharing_mode", sharing_mode)
+ semantic = "1" if body.get("semantic_search") else "0"
+ reranker = "1" if body.get("use_reranker") else "0"
+ compress = "1" if body.get("compress_embeddings") else "0"
+ tcp_enabled = "1" if body.get("tcp_enabled") else "0"
+ transport_host = body.get("transport_host", [""])[0].strip()
+ transport_port = body.get("transport_port", [""])[0].strip()
+ set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
+ set_setting("site_name", name or "tinyweb")
+ set_setting("sharing_enabled", sharing)
+ set_setting("semantic_search", semantic)
+ set_setting("use_reranker", reranker)
+ set_setting("compress_embeddings", compress)
+ set_setting("tcp_enabled", tcp_enabled)
+ if transport_host:
+ set_setting("transport_host", transport_host)
+ if transport_port:
+ set_setting("transport_port", transport_port)
+ lora_enabled = "1" if body.get("lora_enabled") else "0"
+ set_setting("lora_enabled", lora_enabled)
+ set_setting("lora_port", body.get("lora_port", [""])[0].strip())
+ set_setting("lora_frequency", body.get("lora_frequency", ["867200000"])[0].strip())
+ set_setting("lora_bandwidth", body.get("lora_bandwidth", ["125000"])[0].strip())
+ set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
+ set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
+ set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
+ forum_enabled = "1" if body.get("forum_enabled") else "0"
+ current_forum = get_setting("forum_enabled", "0")
+ if forum_enabled != current_forum:
+ from handlers import forum_plugin
+ if forum_enabled == "1" and forum_plugin is None:
+ return handle_style_form(
+ "Forum plugin not installed. Run: pip install tinyweb-forum"
+ )
+ if forum_enabled == "1":
+ forum_plugin.enable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "1")
+ except Exception:
+ pass
+ else:
+ forum_plugin.disable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "0")
+ except Exception:
+ pass
+ set_setting("forum_enabled", forum_enabled)
+ templates_mod.FORUM_ENABLED = (forum_enabled == "1")
+ return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.")
+
+
+def handle_about():
+ name = get_site_name()
+ dest_hash = get_setting("dest_hash")
+ sharing = get_setting("sharing_enabled", "0") == "1"
+ db = get_db()
+ try:
+ page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
+ tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0]
+ sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0]
+ finally:
+ return_db(db)
+
+ sharing_html = (
+ 'This instance shares its index publicly. Subscribe to join the network.
'
+ if sharing else
+ 'This instance is private.
'
+ )
+
+ hash_html = ""
+ if dest_hash:
+ hash_html = (
+ f'subscribe '
+ f'To subscribe to this instance, add this destination hash in your TinyWeb:
'
+ f'{esc(dest_hash)} '
+ )
+
+ return _respond(
+ f'{esc(name)} '
+ f'A personal, decentralized search engine.
'
+ f'You save pages you find. They are stored locally and shared over a mesh network '
+ f'so other people can find them too.
'
+ f'Search results come from your index and the indexes of people you are connected to.
'
+ f''
+ f'{page_count} page(s) indexed '
+ f'{tag_count} tag(s) '
+ f'{sub_count} subscription(s) '
+ f' '
+ f'{sharing_html}'
+ f'{hash_html}'
+ f'your data '
+ f'Everything is stored locally under ~/.tinyweb/:
'
+ f''
+ f'tinyweb_identity — your permanent mesh identity. '
+ f'If you lose this file, your destination hash changes and subscribers '
+ f'have to re-subscribe to the new one. '
+ f'index.db — your full reading history: every page, '
+ f'note, tag, and synced remote page. '
+ f'models/ — the semantic search model if you enabled it '
+ f'(redownloadable, safe to delete). '
+ f' '
+ f'Back up ~/.tinyweb/ periodically. '
+ f'Copying the whole directory to another device preserves your identity and index together. '
+ f'The export page gives you a JSON dump of pages only — '
+ f'it does not preserve your identity or subscription state, so it is a migration aid, '
+ f'not a substitute for a full backup.
'
+ f'search | browse | tags
'
+ )
diff --git a/handlers/data.py b/handlers/data.py
new file mode 100644
index 0000000..d3a714f
--- /dev/null
+++ b/handlers/data.py
@@ -0,0 +1,125 @@
+import json
+import threading
+
+from db import get_db, return_db, get_setting, set_setting, index_url
+from templates import esc
+from ._helpers import _respond, _json_response, _redirect, _csrf_field
+
+MAX_EXPORT = 10000
+_reindex_thread = None
+
+
+def handle_export(query=None):
+ try:
+ batch = int((query or {}).get("batch", ["0"])[0])
+ except (TypeError, ValueError):
+ batch = 0
+ db = get_db()
+ try:
+ rows = db.execute(
+ "SELECT url, title, note FROM pages ORDER BY id LIMIT ? OFFSET ?",
+ (MAX_EXPORT, batch * MAX_EXPORT),
+ ).fetchall()
+ finally:
+ return_db(db)
+ 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"import "
+ f"Paste the contents of a tinyweb export file (JSON).
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f'import '
+ 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.")
+
+ MAX_IMPORT = 100
+ if len(data) > MAX_IMPORT:
+ return handle_import_form(f"Too many entries. Maximum is {MAX_IMPORT}.")
+
+ 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_reindex_form():
+ if get_setting("semantic_search", "0") != "1":
+ return _respond(
+ f"semantic search index "
+ f"Semantic search is disabled. Enable it in settings to use embeddings.
"
+ f'back to search
'
+ )
+ db = get_db()
+ try:
+ total_pages = db.execute("SELECT count(*) FROM pages").fetchone()[0]
+ pages_with_chunks = db.execute(
+ "SELECT count(DISTINCT page_id) FROM chunks WHERE page_id IS NOT NULL"
+ ).fetchone()[0]
+ finally:
+ return_db(db)
+ progress = get_setting("reindex_progress", "")
+ status_html = ""
+ if progress:
+ status_html = f'Reindex in progress: {esc(progress)}
'
+ elif _reindex_thread and _reindex_thread.is_alive():
+ status_html = 'Reindex running...
'
+ return _respond(
+ f"semantic search index "
+ f"{pages_with_chunks} of {total_pages} pages have embeddings.
"
+ f'{status_html}'
+ f''
+ f'{_csrf_field()}'
+ f'reindex all pages '
+ f' '
+ f'back to search
'
+ )
+
+
+def handle_reindex_submit(body):
+ global _reindex_thread
+ if _reindex_thread and _reindex_thread.is_alive():
+ return handle_reindex_form()
+
+ def _run():
+ try:
+ from embeddings import reindex_all
+ def progress(current, total):
+ set_setting("reindex_progress", f"{current}/{total}")
+ reindex_all(progress_callback=progress)
+ except Exception:
+ pass
+ finally:
+ set_setting("reindex_progress", "")
+
+ _reindex_thread = threading.Thread(target=_run, daemon=True)
+ _reindex_thread.start()
+ return _redirect("/reindex")
diff --git a/handlers/pages.py b/handlers/pages.py
new file mode 100644
index 0000000..3e213e0
--- /dev/null
+++ b/handlers/pages.py
@@ -0,0 +1,387 @@
+from pathlib import Path
+import json
+import secrets
+from urllib.parse import unquote
+
+from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
+from templates import esc
+from ._helpers import (
+ _csrf_field, _respond, _redirect, _error,
+ _paginate, _page_nav, _get_page_tags, _set_page_tags, _cleanup_orphaned_tags,
+ _get_bookmark_token, _text_response,
+ BROWSE_PER_PAGE,
+)
+
+
+def handle_add_form(msg="", action_type="index", prefill_url=""):
+ if action_type == "subscribe":
+ return _respond(
+ f"subscribe "
+ f"Subscribe to a friend's TinyWeb instance to sync their index
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f'subscribe '
+ f" "
+ f"or add a single site
"
+ f"{msg}
"
+ f'back '
+ )
+ url_value = f'value="{esc(prefill_url)}" ' if prefill_url else ""
+ return _respond(
+ f"add url "
+ f"Add a site to your index
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f' '
+ f' '
+ f'tag: private to exclude from sharing '
+ f'index '
+ f" "
+ f"{msg}
"
+ f'back '
+ )
+
+
+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(">", "")
+ 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}"
+
+ try:
+ title = index_url(url, note, reticulum_dest if reticulum_dest else "")
+ if tags:
+ db = get_db()
+ try:
+ row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
+ if row:
+ _set_page_tags(row["id"], tags, db)
+ db.commit()
+ finally:
+ return_db(db)
+
+ return handle_add_form(f'Indexed: {esc(url)}')
+
+ except ValueError as e:
+ return handle_add_form(f"Error: {esc(str(e))}")
+
+ except Exception as e:
+ error_msg = str(e).lower()
+ if "block" in error_msg or "cloudflare" in error_msg or "403" in error_msg:
+ return _respond(
+ f"add url (manual entry) "
+ f"{esc(url)} blocks automated access. "
+ f"You can still save it manually:
"
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f' '
+ f' '
+ f'Title: '
+ f' '
+ f'Description: '
+ f' '
+ f'save manually '
+ f" "
+ f'back '
+ )
+ return handle_add_form(f"Error: could not fetch or index that URL. {esc(str(e)[:100])}")
+
+
+def handle_add_manual_submit(body):
+ url = clean_url(body.get("url", [""])[0].strip())
+ note = body.get("note", [""])[0].strip()
+ tags = body.get("tags", [""])[0].strip()
+ manual_title = body.get("manual_title", [""])[0].strip()
+ manual_desc = body.get("manual_description", [""])[0].strip()
+
+ if not url:
+ return handle_add_form("URL is required.")
+
+ if not manual_title:
+ return handle_add_form("Title is required for manual entry.")
+
+ db = get_db()
+ try:
+ now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+
+ db.execute(
+ "INSERT INTO pages (url, title, body, note, last_modified, summary) VALUES (?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
+ "note=excluded.note, last_modified=excluded.last_modified, summary=excluded.summary",
+ (url, manual_title, manual_desc, note, now, manual_desc[:200]),
+ )
+
+ page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
+
+ if tags:
+ _set_page_tags(page_id, tags, db)
+
+ db.commit()
+
+ if get_setting("semantic_search", "0") == "1":
+ try:
+ from embeddings import store_embeddings
+ store_embeddings(page_id, manual_title, manual_desc, db)
+ db.commit()
+ except Exception as e:
+ print(f"Error generating embeddings: {e}")
+
+ return handle_add_form(f'Added manually: {esc(manual_title)} ')
+ finally:
+ return_db(db)
+
+
+def handle_pages(query=None):
+ msg = query.get("msg", [""])[0] if query else ""
+ msg_html = f'{esc(msg)}
' if msg else ""
+ page = _paginate(query or {})
+ offset = (page - 1) * BROWSE_PER_PAGE
+ db = get_db()
+ try:
+ total = db.execute("SELECT count(*) FROM pages").fetchone()[0]
+ rows = db.execute(
+ "SELECT id, url, title, note FROM pages ORDER BY id DESC LIMIT ? OFFSET ?",
+ (BROWSE_PER_PAGE, offset),
+ ).fetchall()
+ items = ""
+ for r in rows:
+ note_html = f' — {esc(r["note"])} ' if r["note"] else ""
+ tags = _get_page_tags(r["id"], db)
+ tags_html = ""
+ if tags:
+ tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
+ tags_html = f' {tag_links}'
+ items += (
+ f' '
+ f'{esc(r["title"])} {note_html}{tags_html} '
+ f'({esc(r["url"])} ) '
+ f'edit '
+ f'remove '
+ )
+ finally:
+ return_db(db)
+ return _respond(
+ f"indexed pages ({total}) "
+ f"{msg_html}"
+ f''
+ f'{_csrf_field()}'
+ f' select all
'
+ f""
+ f'{_page_nav(page, total, "/pages", BROWSE_PER_PAGE)}'
+ f'bulk actions '
+ f'delete selected
'
+ f' '
+ f'add tags replace tags '
+ f'retag selected
'
+ f' '
+ f' '
+ f''
+ f'export | import
'
+ f'back '
+ )
+
+
+def _render_bulk_delete_confirm(page_ids):
+ db = get_db()
+ try:
+ placeholders = ",".join("?" * len(page_ids))
+ rows = db.execute(
+ f"SELECT id, url, title FROM pages WHERE id IN ({placeholders})",
+ page_ids,
+ ).fetchall()
+ finally:
+ return_db(db)
+ if not rows:
+ return _redirect("/pages")
+ items = "".join(
+ f'{esc(r["title"] or r["url"])} '
+ f'{esc(r["url"])} '
+ for r in rows
+ )
+ hidden_ids = "".join(
+ f' ' for r in rows
+ )
+ n = len(rows)
+ return _respond(
+ f"confirm delete "
+ f"Remove the following {n} page{'' if n == 1 else 's'}?
"
+ f""
+ f''
+ f'{_csrf_field()}'
+ f'{hidden_ids}'
+ f' '
+ f' '
+ f'yes, delete {n} page{"" if n == 1 else "s"} '
+ f" "
+ f' cancel '
+ )
+
+
+def handle_bulk_action(body):
+ ids = body.get("ids", [])
+ action = body.get("action", [""])[0]
+ if not ids:
+ return _redirect("/pages")
+ try:
+ page_ids = [int(i) for i in ids]
+ except ValueError:
+ return _error(400)
+ if action == "delete" and body.get("confirmed", [""])[0] != "1":
+ return _render_bulk_delete_confirm(page_ids)
+ db = get_db()
+ try:
+ if action == "delete":
+ for pid in page_ids:
+ db.execute("DELETE FROM page_tags WHERE page_id = ?", (pid,))
+ db.execute("DELETE FROM links WHERE page_id = ?", (pid,))
+ db.execute("DELETE FROM pages WHERE id = ?", (pid,))
+ _cleanup_orphaned_tags(db)
+ db.commit()
+ elif action == "retag":
+ bulk_tags = body.get("bulk_tags", [""])[0].strip()
+ tag_mode = body.get("tag_mode", ["add"])[0]
+ if bulk_tags:
+ for pid in page_ids:
+ if tag_mode == "add":
+ existing = _get_page_tags(pid, db)
+ new_tags = [t.strip().lower() for t in bulk_tags.split(",") if t.strip()]
+ merged = ", ".join(sorted(set(existing + new_tags)))
+ _set_page_tags(pid, merged, db)
+ else:
+ _set_page_tags(pid, bulk_tags, db)
+ _cleanup_orphaned_tags(db)
+ db.commit()
+ finally:
+ return_db(db)
+ return _redirect("/pages")
+
+
+def handle_edit_form(page_id, msg=""):
+ db = get_db()
+ try:
+ row = db.execute("SELECT id, url, title, body, note, summary FROM pages WHERE id = ?", (page_id,)).fetchone()
+ if not row:
+ return _error(404)
+ tags = ", ".join(_get_page_tags(page_id, db))
+ finally:
+ return_db(db)
+
+ return _respond(
+ f"edit page "
+ f"{esc(row['title'])} "
+ f"{esc(row['url'])}
"
+ f''
+ f'{_csrf_field()}'
+ f'Title: '
+ f' '
+ f'Summary (shown in search results): '
+ f'{esc(row["summary"] or "")} '
+ f'Note (why you saved this): '
+ f' '
+ f'Tags (comma-separated): '
+ f' '
+ f'(tag: private to keep private) '
+ f'save '
+ f" "
+ f"{msg}
"
+ f'back '
+ )
+
+
+def handle_edit_submit(page_id, body):
+ title = body.get("title", [""])[0].strip()
+ summary = body.get("summary", [""])[0].strip()
+ note = body.get("note", [""])[0].strip()
+ tags = body.get("tags", [""])[0].strip()
+
+ db = get_db()
+ try:
+ db.execute(
+ "UPDATE pages SET title = ?, summary = ?, note = ? WHERE id = ?",
+ (title, summary, note, page_id)
+ )
+
+ _set_page_tags(page_id, tags, db)
+ _cleanup_orphaned_tags(db)
+
+ db.commit()
+
+ finally:
+ return_db(db)
+
+ return _redirect("/pages")
+
+
+def handle_delete_confirm(page_id):
+ db = get_db()
+ try:
+ row = db.execute("SELECT id, url, title FROM pages WHERE id = ?", (page_id,)).fetchone()
+ finally:
+ return_db(db)
+ if not row:
+ return _error(404)
+ return _respond(
+ f"confirm delete "
+ f"Remove {esc(row['title'])} "
+ f"{esc(row['url'])}
"
+ f''
+ f'{_csrf_field()}'
+ f'yes, delete '
+ f" "
+ f' cancel '
+ )
+
+
+def handle_delete(page_id):
+ db = get_db()
+ try:
+ db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
+ db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
+ db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
+ _cleanup_orphaned_tags(db)
+ db.commit()
+ finally:
+ return_db(db)
+ return _redirect("/pages")
+
+
+def handle_bookmark(query):
+ token = query.get("token", [""])[0]
+ expected = _get_bookmark_token()
+ if not token or not secrets.compare_digest(token, expected):
+ return _text_response("error: invalid or missing token", status=403, headers={"Access-Control-Allow-Origin": "*"})
+ url = clean_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": "*"})
diff --git a/handlers/search.py b/handlers/search.py
new file mode 100644
index 0000000..967b59c
--- /dev/null
+++ b/handlers/search.py
@@ -0,0 +1,171 @@
+from db import get_db, return_db, get_setting, get_site_name, clean_url
+from templates import esc
+from ._helpers import _sanitize_fts_query, _paginate, _get_page_tags, _respond, _page_nav, PER_PAGE
+
+
+def handle_search(query):
+ q = query.get("q", [""])[0].strip()
+ page = _paginate(query)
+ offset = (page - 1) * PER_PAGE
+ db = get_db()
+ try:
+ count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
+ name = get_site_name()
+
+ result_html = ""
+ trusted_html = ""
+ if q:
+ try:
+ fts_q = _sanitize_fts_query(q)
+ bm25_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 bm25(pages_fts, 10.0, 1.0, 5.0, 3.0) LIMIT 100",
+ (fts_q,),
+ ).fetchall()
+ except Exception:
+ bm25_rows = []
+
+ bm25_ids = [r["id"] for r in bm25_rows]
+ chunk_snippets = {}
+ if get_setting("semantic_search", "0") == "1":
+ try:
+ from embeddings import hybrid_search
+ use_reranker = get_setting("use_reranker", "1") == "1"
+ fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
+ fused_ids = [pid for pid, _ in fused]
+ chunk_snippets = {pid: text for pid, text in fused if text}
+ except Exception:
+ fused_ids = bm25_ids
+ else:
+ fused_ids = bm25_ids
+
+ total_results = len(fused_ids)
+ page_ids = fused_ids[offset:offset + PER_PAGE]
+
+ if page_ids:
+ placeholders = ",".join("?" * len(page_ids))
+ all_rows = db.execute(
+ f"SELECT id, url, title, body, note, summary FROM pages WHERE id IN ({placeholders})",
+ page_ids,
+ ).fetchall()
+ row_map = {r["id"]: r for r in all_rows}
+ rows = [row_map[pid] for pid in page_ids if pid in row_map]
+ else:
+ rows = []
+
+ if rows:
+ for r in rows:
+ note_html = ""
+ if r["note"]:
+ note_html = f'{esc(r["note"])}
'
+ tags = _get_page_tags(r["id"], db)
+ tags_html = ""
+ if tags:
+ tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
+ tags_html = f'{tag_links}
'
+ snip_html = f' {esc(r["summary"])}' if r["summary"] else ""
+ result_html += (
+ f''
+ f'
{esc(r["title"])} '
+ f'
{esc(r["url"])} '
+ f'{snip_html}'
+ f'{note_html}{tags_html}'
+ f'
'
+ )
+ else:
+ result_html = "No results in your index.
"
+
+ 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'{esc(l["label"])} '
+ f'— from {esc(l["source_title"])} '
+ )
+ trusted_html = (
+ f''
+ f'from your trusted sites ({len(trusted)}) '
+ f''
+ f' '
+ )
+
+ try:
+ 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",
+ (_sanitize_fts_query(q),),
+ ).fetchall()
+ except Exception:
+ remote_rows = []
+
+ remote_html = ""
+ if q and remote_rows:
+ 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' — {esc(r["note"])} ' if r["note"] else ""
+ source_items += (
+ f'{esc(r["title"])} '
+ f'{note_html} ({esc(clean_url(r["url"]))}) '
+ )
+ remote_html += (
+ f''
+ f'from {esc(source)} ({len(items)}) '
+ f''
+ f' '
+ )
+ finally:
+ return_db(db)
+ 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 = (
+ ''
+ )
+ return _respond(
+ f''
+ f' '
+ f' search '
+ f' '
+ f'{count} pages indexed'
+ f' · + add url
'
+ f'{welcome_html}'
+ f'{result_html}'
+ f'{_page_nav(page, total_results, f"/?q={esc(q)}") if q else ""}'
+ f'{trusted_html}{remote_html}'
+ )
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
new file mode 100644
index 0000000..b54a253
--- /dev/null
+++ b/handlers/subscriptions.py
@@ -0,0 +1,455 @@
+import threading
+from datetime import datetime
+
+from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
+from templates import esc
+from rns_client import fetch_remote_sites
+from ._helpers import (
+ _get_page_tags, _respond, _redirect, _json_response, _error,
+ _csrf_field,
+)
+
+_sync_threads = {}
+
+MAX_API_SITES = 5000
+MAX_BROWSE = 5000
+
+
+def _page_is_shared(tags, mode):
+ if "private" in tags:
+ return False
+ if mode == "require_public" and "public" not in tags:
+ return False
+ return True
+
+
+def _shared_sites(db, since=""):
+ mode = get_setting("sharing_mode", "exclude_private")
+ if since:
+ rows = db.execute(
+ "SELECT id, url, title, note, last_modified FROM pages "
+ "WHERE last_modified > ? ORDER BY id DESC LIMIT ?",
+ (since, MAX_API_SITES),
+ ).fetchall()
+ else:
+ rows = db.execute(
+ "SELECT id, url, title, note, last_modified FROM pages ORDER BY id DESC LIMIT ?",
+ (MAX_API_SITES,),
+ ).fetchall()
+ sites = []
+ for r in rows:
+ tags = _get_page_tags(r["id"], db)
+ if not _page_is_shared(tags, mode):
+ continue
+ sites.append({
+ "url": r["url"], "title": r["title"], "note": r["note"],
+ "tags": tags, "last_modified": r["last_modified"] or "",
+ })
+ return sites
+
+
+def _shared_all_urls(db):
+ mode = get_setting("sharing_mode", "exclude_private")
+ rows = db.execute(
+ "SELECT id, url FROM pages ORDER BY id DESC LIMIT ?", (MAX_API_SITES,)
+ ).fetchall()
+ return [r["url"] for r in rows if _page_is_shared(_get_page_tags(r["id"], db), mode)]
+
+
+def _count_shared_pages():
+ db = get_db()
+ try:
+ return len(_shared_all_urls(db))
+ finally:
+ return_db(db)
+
+
+def handle_share_preview():
+ mode = get_setting("sharing_mode", "exclude_private")
+ mode_label = (
+ "only pages tagged public"
+ if mode == "require_public"
+ else "all pages except those tagged private"
+ )
+ sharing_on = get_setting("sharing_enabled", "0") == "1"
+ status = (
+ 'Sharing is enabled . Subscribers see the pages listed below.
'
+ if sharing_on else
+ 'Sharing is disabled . Nothing is actually being shared right now; '
+ 'this is the list that would be exposed if you enabled it.
'
+ )
+ db = get_db()
+ try:
+ sites = _shared_sites(db)
+ finally:
+ return_db(db)
+ if not sites:
+ body = (
+ "sharing preview "
+ f"Rule: {mode_label}.
"
+ f"{status}"
+ "No pages match the current rule.
"
+ 'back to settings
'
+ )
+ return _respond(body)
+ rows = ""
+ for s in sites:
+ tags_html = ""
+ if s["tags"]:
+ tags_html = " " + " ".join(f"[{esc(t)}]" for t in s["tags"])
+ note_html = f' — {esc(s["note"])} ' if s["note"] else ""
+ rows += (
+ f''
+ f'{esc(s["title"] or s["url"])} '
+ f'{note_html}{tags_html} '
+ f'{esc(s["url"])} '
+ f' '
+ )
+ body = (
+ "sharing preview "
+ f"Rule: {mode_label}.
"
+ f"{status}"
+ f"{len(sites)} page(s) visible to subscribers.
"
+ f""
+ 'back to settings
'
+ )
+ return _respond(body)
+
+
+def handle_api_sites(query=None):
+ if get_setting("sharing_enabled", "0") != "1":
+ return _json_response(
+ {"error": "sharing disabled"},
+ status=403,
+ headers={"Access-Control-Allow-Origin": "*"},
+ )
+ since = (query or {}).get("since", [""])[0].strip()
+ db = get_db()
+ try:
+ sites = _shared_sites(db, since=since)
+ all_urls = _shared_all_urls(db) if not since else None
+ finally:
+ return_db(db)
+ data = {"name": get_site_name(), "sites": sites}
+ if all_urls is not None:
+ data["all_urls"] = all_urls
+ return _json_response(data, headers={"Access-Control-Allow-Origin": "*"})
+
+
+def handle_subscriptions(msg=""):
+ db = get_db()
+ try:
+ subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
+ finally:
+ return_db(db)
+ cards = ""
+ for s in subs:
+ sub_id = s["id"]
+ auto_label = "on" if s["auto_sync"] else "off"
+ last = s["last_sync"] or "never"
+ sync_status = get_setting(f"sync_status_{sub_id}", "")
+ is_syncing = sub_id in _sync_threads and _sync_threads[sub_id].is_alive()
+
+ if is_syncing:
+ status_html = 'syncing...
'
+ elif sync_status.startswith("error:"):
+ err_msg = sync_status[6:]
+ status_html = f'{esc(err_msg)}
'
+ else:
+ status_html = ""
+
+ if is_syncing:
+ sync_btn = 'syncing... '
+ else:
+ sync_btn = (
+ f''
+ f'{_csrf_field()}sync now '
+ )
+
+ cards += (
+ f''
+ f'
{esc(s["name"] or "unknown")}
'
+ f'
{esc(s["dest_hash"])}
'
+ f'
last sync: {esc(last)}
'
+ f'{status_html}'
+ f'
'
+ f'
browse '
+ f'{sync_btn}'
+ f'
'
+ f'{_csrf_field()}auto-sync: {auto_label} '
+ f'
'
+ f'{_csrf_field()}remove '
+ f'
'
+ f'
'
+ )
+ listing = ""
+ if subs:
+ any_syncing = any(sid in _sync_threads and _sync_threads[sid].is_alive() for sid in [s["id"] for s in subs])
+ syncall_btn = 'syncing... ' if any_syncing else 'sync all '
+ listing = (
+ f'{cards}'
+ f''
+ f'{_csrf_field()}{syncall_btn} '
+ )
+ return _respond(
+ f"subscriptions "
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f'subscribe '
+ f' '
+ f'or subscribe to an instance
'
+ f'{msg}
'
+ f' {listing}'
+ f'back '
+ )
+
+
+def handle_subscription_add(body):
+ 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:
+ 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:
+ return handle_subscriptions("Could not reach that instance.")
+ db = get_db()
+ try:
+ db.execute(
+ "INSERT INTO subscriptions (dest_hash, name) VALUES (?, ?) "
+ "ON CONFLICT(dest_hash) DO UPDATE SET name=excluded.name",
+ (dest_hash, name),
+ )
+ db.commit()
+ finally:
+ return_db(db)
+ return handle_subscriptions(f"Subscribed to {esc(name or dest_hash)}.")
+
+
+def handle_subscription_browse(sub_id):
+ db = get_db()
+ try:
+ sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
+ if not sub:
+ return _error(404)
+ local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages LIMIT ?", (MAX_BROWSE,)).fetchall())
+
+ remote_rows = db.execute(
+ "SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ? LIMIT ?",
+ (sub_id, MAX_BROWSE),
+ ).fetchall()
+ finally:
+ return_db(db)
+
+ if remote_rows:
+ sites = []
+ for r in remote_rows:
+ tags = [t for t in r["tags"].split(",") if t] if r["tags"] else []
+ sites.append({"url": r["url"], "title": r["title"], "note": r["note"], "tags": tags})
+ else:
+ try:
+ data = fetch_remote_sites(sub["dest_hash"])
+ sites = data.get("sites", [])
+ except PermissionError:
+ return handle_subscriptions("That instance has sharing disabled.")
+ except Exception:
+ return handle_subscriptions("Could not fetch sites from that instance.")
+
+ new_items = ""
+ existing_items = ""
+ new_count = 0
+ for s in sites:
+ if s["url"] in local_urls:
+ existing_items += (
+ f'{esc(s["title"])} '
+ f'({esc(s["url"])}) — already indexed '
+ )
+ else:
+ new_count += 1
+ note_html = f' — {esc(s["note"])} ' if s.get("note") else ""
+ tags_html = ""
+ if s.get("tags"):
+ tags_html = " " + " ".join(f'[{esc(t)}]' for t in s["tags"])
+ new_items += (
+ f' '
+ f' {esc(s["title"])}{note_html}{tags_html}'
+ f' ({esc(s["url"])}) '
+ )
+
+ buttons = ""
+ if new_count:
+ buttons = 'import selected import all new '
+ return _respond(
+ f'browsing: {esc(sub["name"] or sub["dest_hash"])} '
+ f'{len(sites)} site(s) available, {new_count} new
'
+ f''
+ f'{_csrf_field()}'
+ f' '
+ f''
+ f'{buttons}'
+ f' '
+ f'already indexed '
+ f'back '
+ )
+
+
+def handle_subscription_pick(body):
+ sub_id = body.get("sub_id", [""])[0]
+ import_all = body.get("import_all", [""])[0]
+
+ db = get_db()
+ try:
+ remote_rows = db.execute(
+ "SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,)
+ ).fetchall()
+ remote_tags = {r["url"]: r["tags"] for r in remote_rows}
+
+ if import_all:
+ local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages LIMIT ?", (MAX_BROWSE,)).fetchall())
+ urls = [r["url"] for r in remote_rows if r["url"] not in local_urls]
+ else:
+ urls = body.get("urls", [])
+ finally:
+ return_db(db)
+
+ if not urls:
+ return handle_subscriptions("No sites selected.")
+
+ imported = 0
+ errors = 0
+ for url in urls:
+ try:
+ index_url(url)
+ tags_str = remote_tags.get(url, "")
+ if tags_str:
+ db = get_db()
+ try:
+ row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
+ if row:
+ _set_page_tags(row["id"], tags_str, db)
+ db.commit()
+ finally:
+ return_db(db)
+ imported += 1
+ except Exception:
+ errors += 1
+ return handle_subscriptions(f"Imported {imported} page(s). {errors} error(s).")
+
+
+def _sync_subscription(sub_id):
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ db = get_db()
+ try:
+ sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
+ if not sub:
+ set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
+ return
+ since = sub["last_sync"].replace(" ", "T") if sub["last_sync"] else ""
+ try:
+ data = fetch_remote_sites(sub["dest_hash"], since=since)
+ sites = data.get("sites", [])
+ all_urls = data.get("all_urls")
+ remote_name = data.get("name", sub["name"])
+ except PermissionError:
+ set_setting(f"sync_status_{sub_id}", "error:That instance has sharing disabled.")
+ return
+ except Exception as e:
+ set_setting(f"sync_status_{sub_id}", f"error:Could not sync \u2014 {e}")
+ return
+
+ if all_urls is not None:
+ existing = db.execute(
+ "SELECT id, url FROM remote_pages WHERE subscription_id = ?", (sub_id,)
+ ).fetchall()
+ remote_url_set = set(all_urls)
+ for row in existing:
+ if row["url"] not in remote_url_set:
+ db.execute("DELETE FROM remote_pages WHERE id = ?", (row["id"],))
+
+ synced = 0
+ for s in sites:
+ try:
+ tags_str = ",".join(s.get("tags", []))
+ db.execute(
+ "INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?) "
+ "ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
+ (sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
+ )
+ if get_setting("semantic_search", "0") == "1":
+ try:
+ from embeddings import store_remote_embeddings
+ rp_id = db.execute(
+ "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
+ (sub_id, s["url"]),
+ ).fetchone()["id"]
+ store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
+ except Exception:
+ pass
+ synced += 1
+ except Exception:
+ pass
+ now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+ db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
+ db.commit()
+ set_setting(f"sync_status_{sub_id}", f"done:{synced}")
+ except Exception as e:
+ set_setting(f"sync_status_{sub_id}", f"error:{e}")
+ finally:
+ return_db(db)
+
+
+def handle_subscription_sync(sub_id):
+ if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
+ return _redirect("/subscriptions")
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
+ _sync_threads[sub_id] = t
+ t.start()
+ return _redirect("/subscriptions")
+
+
+def handle_subscription_autosync(sub_id):
+ db = get_db()
+ try:
+ db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,))
+ db.commit()
+ finally:
+ return_db(db)
+ return _redirect("/subscriptions")
+
+
+def handle_subscription_delete(sub_id):
+ db = get_db()
+ try:
+ db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
+ db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,))
+ db.commit()
+ finally:
+ return_db(db)
+ return _redirect("/subscriptions")
+
+
+def handle_subscription_syncall():
+ db = get_db()
+ try:
+ subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall()
+ finally:
+ return_db(db)
+ if not subs:
+ return handle_subscriptions("No subscriptions have auto-sync enabled.")
+ for sub in subs:
+ sub_id = sub["id"]
+ if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
+ continue
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
+ _sync_threads[sub_id] = t
+ t.start()
+ return _redirect("/subscriptions")
diff --git a/handlers/tags.py b/handlers/tags.py
new file mode 100644
index 0000000..f0e927d
--- /dev/null
+++ b/handlers/tags.py
@@ -0,0 +1,59 @@
+from db import get_db, return_db
+from templates import esc
+from ._helpers import _respond, _paginate, _page_nav, _get_page_tags, BROWSE_PER_PAGE
+
+
+def handle_tags():
+ db = get_db()
+ try:
+ rows = db.execute(
+ "SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t "
+ "JOIN page_tags pt ON t.id = pt.tag_id "
+ "GROUP BY t.id ORDER BY t.name"
+ ).fetchall()
+ finally:
+ return_db(db)
+ items = ""
+ for r in rows:
+ items += f'{esc(r["name"])} ({r["cnt"]}) '
+ return _respond(
+ f"tags "
+ f"" if items else "No tags yet. Add tags when saving or editing pages.
"
+ f'back '
+ )
+
+
+def handle_tag_browse(tag_name, query=None):
+ page = _paginate(query or {})
+ offset = (page - 1) * BROWSE_PER_PAGE
+ db = get_db()
+ try:
+ total = db.execute(
+ "SELECT count(*) FROM page_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ?",
+ (tag_name,),
+ ).fetchone()[0]
+ rows = db.execute(
+ "SELECT p.id, p.url, p.title, p.note FROM pages p "
+ "JOIN page_tags pt ON p.id = pt.page_id "
+ "JOIN tags t ON t.id = pt.tag_id "
+ "WHERE t.name = ? ORDER BY p.id DESC LIMIT ? OFFSET ?",
+ (tag_name, BROWSE_PER_PAGE, offset),
+ ).fetchall()
+ items = ""
+ for r in rows:
+ note_html = f' — {esc(r["note"])} ' if r["note"] else ""
+ tags = _get_page_tags(r["id"], db)
+ tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
+ items += (
+ f'{esc(r["title"])}{note_html} {tag_links} '
+ f'({esc(r["url"])} ) '
+ )
+ finally:
+ return_db(db)
+ return _respond(
+ f'tag: {esc(tag_name)} '
+ f'{total} page(s)
'
+ f''
+ f'{_page_nav(page, total, f"/tags/{esc(tag_name)}", BROWSE_PER_PAGE)}'
+ f'all tags | back '
+ )
From 728be053fb5843eaf3eee25d93d3eb7c817cea18 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 03:06:38 +0000
Subject: [PATCH 141/194] README: add transparency sections, TOC, update
project structure
---
README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 64 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 90c10c8..eca20ee 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,27 @@
A personal, decentralized search engine built on the [Reticulum](https://reticulum.network/) mesh network. You save pages you find. They are stored locally and shared over a mesh network so other people can find them too.
+## Contents
+
+- [About this project](#about-this-project)
+- [Features](#features)
+- [Performance & Scale](#performance--scale)
+- [Docker](#docker)
+- [Data storage](#data-storage)
+- [Getting started](#getting-started)
+- [Remote gateway](#remote-gateway)
+- [How it works](#how-it-works)
+- [Known rough edges](#known-rough-edges)
+- [Forum plugin](#forum-plugin)
+- [Project structure](#project-structure)
+- [Security](#security)
+- [Maintenance](#maintenance)
+- [Dependencies](#dependencies)
+
+## About this project
+
+Code generated by LLMs. Built by one person.
+
## Features
- **Personal search index** — Save pages you find valuable, search them with full-text search (SQLite FTS5)
@@ -13,6 +34,19 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- **Mesh-native** — Works over Reticulum without the internet; encrypted and decentralized by default
- **Forum plugin** — Optional link-sharing discussion board over the mesh (see Forum section below)
+### What sharing means
+
+Once a subscriber syncs your pages, you have no control over their copy.
+No revocation, no DRM, no expiry. If you shared a page, assume it's out
+there permanently.
+
+Tag-based sharing (`private`, `public`) is advisory. The software
+respects these tags in its API response, but there is no technical
+mechanism preventing a subscriber from re-sharing your data.
+
+Deletion is local only. Removing a page from your index does not
+propagate to subscribers.
+
## Performance & Scale
### Search Speed
@@ -166,6 +200,18 @@ This connects over Reticulum and serves the remote instance at `http://localhost
3. **Subscribe** — Add a friend's destination hash on `/subscriptions` to sync their shared index
4. **Customize** — Edit your site name, HTML template, and sharing settings on `/style`
+## Known rough edges
+
+- Single-user UI
+- All-or-nothing sharing per mode
+- Manual sync (except optional forum auto-sync)
+- No recrawling
+- No browser extension — bookmarklet only
+- Desktop-oriented
+- JSON-only import
+- Forum threads prune after 30 days by default
+- Best-effort maintenance
+
## Forum plugin
TinyWeb ships with an optional [tinyweb-forum](https://codeberg.org/tinyweb/tinyweb-forum) plugin — a decentralized link-sharing discussion board that runs in-process alongside TinyWeb.
@@ -196,7 +242,15 @@ For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyw
```
app.py — Entry point: boots Reticulum, starts HTTP gateway
gateway.py — HTTP-to-RNS bridge (local or remote dispatch)
-handlers.py — Route dispatcher and all request handlers
+handlers/ — Route dispatcher and request handlers
+ __init__.py — Dispatch logic + re-exports
+ _helpers.py — CSRF, FTS sanitizer, pagination, response builders
+ search.py — Search (BM25, hybrid, trusted/remote results)
+ pages.py — Add/edit/delete/bulk/bookmark handlers
+ subscriptions.py — Sync, sharing, API, subscription CRUD
+ customize.py — Settings form, about page
+ tags.py — Tag list and browse
+ data.py — Export, import, semantic reindex
db.py — SQLite database, FTS5, URL fetching, SSRF protection
templates.py — HTML template rendering and escaping
rns_client.py — Reticulum client for fetching remote site lists
@@ -218,6 +272,15 @@ Other hardening measures:
- **Identity file protection** — The Reticulum identity key is restricted to owner-only permissions (0600)
- **Forum caveats** — See [tinyweb-forum Security](https://codeberg.org/tinyweb/tinyweb-forum#security) for forum-specific risks (voluntary retractions, block gossip manipulation, no rate limiting)
+#### Not hardened
+
+- No HTTPS
+- No authentication
+- No encryption-at-rest
+- No rate limiting
+- Bookmarklet token sent as a plain URL parameter
+- Forum moderation is gossip-based — block lists can be manipulated
+
## Maintenance
### Database Vacuum
From bddfb70fc0651f2be04a8b1a24db13649686dee2 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 03:06:38 +0000
Subject: [PATCH 142/194] README: add transparency sections, TOC, update
project structure
---
README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 64 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 90c10c8..eca20ee 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,27 @@
A personal, decentralized search engine built on the [Reticulum](https://reticulum.network/) mesh network. You save pages you find. They are stored locally and shared over a mesh network so other people can find them too.
+## Contents
+
+- [About this project](#about-this-project)
+- [Features](#features)
+- [Performance & Scale](#performance--scale)
+- [Docker](#docker)
+- [Data storage](#data-storage)
+- [Getting started](#getting-started)
+- [Remote gateway](#remote-gateway)
+- [How it works](#how-it-works)
+- [Known rough edges](#known-rough-edges)
+- [Forum plugin](#forum-plugin)
+- [Project structure](#project-structure)
+- [Security](#security)
+- [Maintenance](#maintenance)
+- [Dependencies](#dependencies)
+
+## About this project
+
+Code generated by LLMs. Built by one person.
+
## Features
- **Personal search index** — Save pages you find valuable, search them with full-text search (SQLite FTS5)
@@ -13,6 +34,19 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- **Mesh-native** — Works over Reticulum without the internet; encrypted and decentralized by default
- **Forum plugin** — Optional link-sharing discussion board over the mesh (see Forum section below)
+### What sharing means
+
+Once a subscriber syncs your pages, you have no control over their copy.
+No revocation, no DRM, no expiry. If you shared a page, assume it's out
+there permanently.
+
+Tag-based sharing (`private`, `public`) is advisory. The software
+respects these tags in its API response, but there is no technical
+mechanism preventing a subscriber from re-sharing your data.
+
+Deletion is local only. Removing a page from your index does not
+propagate to subscribers.
+
## Performance & Scale
### Search Speed
@@ -166,6 +200,18 @@ This connects over Reticulum and serves the remote instance at `http://localhost
3. **Subscribe** — Add a friend's destination hash on `/subscriptions` to sync their shared index
4. **Customize** — Edit your site name, HTML template, and sharing settings on `/style`
+## Known rough edges
+
+- Single-user UI
+- All-or-nothing sharing per mode
+- Manual sync (except optional forum auto-sync)
+- No recrawling
+- No browser extension — bookmarklet only
+- Desktop-oriented
+- JSON-only import
+- Forum threads prune after 30 days by default
+- Best-effort maintenance
+
## Forum plugin
TinyWeb ships with an optional [tinyweb-forum](https://codeberg.org/tinyweb/tinyweb-forum) plugin — a decentralized link-sharing discussion board that runs in-process alongside TinyWeb.
@@ -196,7 +242,15 @@ For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyw
```
app.py — Entry point: boots Reticulum, starts HTTP gateway
gateway.py — HTTP-to-RNS bridge (local or remote dispatch)
-handlers.py — Route dispatcher and all request handlers
+handlers/ — Route dispatcher and request handlers
+ __init__.py — Dispatch logic + re-exports
+ _helpers.py — CSRF, FTS sanitizer, pagination, response builders
+ search.py — Search (BM25, hybrid, trusted/remote results)
+ pages.py — Add/edit/delete/bulk/bookmark handlers
+ subscriptions.py — Sync, sharing, API, subscription CRUD
+ customize.py — Settings form, about page
+ tags.py — Tag list and browse
+ data.py — Export, import, semantic reindex
db.py — SQLite database, FTS5, URL fetching, SSRF protection
templates.py — HTML template rendering and escaping
rns_client.py — Reticulum client for fetching remote site lists
@@ -218,6 +272,15 @@ Other hardening measures:
- **Identity file protection** — The Reticulum identity key is restricted to owner-only permissions (0600)
- **Forum caveats** — See [tinyweb-forum Security](https://codeberg.org/tinyweb/tinyweb-forum#security) for forum-specific risks (voluntary retractions, block gossip manipulation, no rate limiting)
+#### Not hardened
+
+- No HTTPS
+- No authentication
+- No encryption-at-rest
+- No rate limiting
+- Bookmarklet token sent as a plain URL parameter
+- Forum moderation is gossip-based — block lists can be manipulated
+
## Maintenance
### Database Vacuum
From 804c5701c746a9339da23625c80de9a16eb6533e Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 03:11:43 +0000
Subject: [PATCH 143/194] add SECURITY.md
---
SECURITY.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 SECURITY.md
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..78d2c70
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1 @@
+If you find a security issue, report it privately by emailing security@tinyweb. Please don't file a public issue.
From 571a8fe406d53ff8b0b1d07f5615d5646f23d01b Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 03:11:43 +0000
Subject: [PATCH 144/194] add SECURITY.md
---
SECURITY.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 SECURITY.md
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..f294f81
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1 @@
+If you find a security issue, report it privately by emailing blankie@tuta.com. Please don't file a public issue.
From 72b0478cb67e8dfa16782157464ea3b498f56847 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 03:15:55 +0000
Subject: [PATCH 145/194] add CONTRIBUTING.md and issue template
---
.github/ISSUE_TEMPLATE.md | 7 +++++++
CONTRIBUTING.md | 3 +++
2 files changed, 10 insertions(+)
create mode 100644 .github/ISSUE_TEMPLATE.md
create mode 100644 CONTRIBUTING.md
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000..3105410
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,7 @@
+**Describe the issue**
+
+**Steps to reproduce**
+
+**Expected vs actual behavior**
+
+**Environment (OS, Python version, install method)**
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6807493
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+This is a one-person project maintained in spare time. Bug reports and patches are welcome, but there are no guarantees around response time, acceptance, or ongoing support.
+
+Code was generated by LLMs. If you submit a PR, assume I'll review it the same way — I'll look for whether it works and whether it fits, not whether it's idiomatic or perfectly styled.
From 7fd46ad6a7ed7f7583b732572e9e908d2be94ec3 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 03:15:55 +0000
Subject: [PATCH 146/194] add CONTRIBUTING.md and issue template
---
.github/ISSUE_TEMPLATE.md | 7 +++++++
CONTRIBUTING.md | 3 +++
2 files changed, 10 insertions(+)
create mode 100644 .github/ISSUE_TEMPLATE.md
create mode 100644 CONTRIBUTING.md
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000..3105410
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,7 @@
+**Describe the issue**
+
+**Steps to reproduce**
+
+**Expected vs actual behavior**
+
+**Environment (OS, Python version, install method)**
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6807493
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,3 @@
+This is a one-person project maintained in spare time. Bug reports and patches are welcome, but there are no guarantees around response time, acceptance, or ongoing support.
+
+Code was generated by LLMs. If you submit a PR, assume I'll review it the same way — I'll look for whether it works and whether it fits, not whether it's idiomatic or perfectly styled.
From 0527237e23d1c516693ae1574d6c08a4c4df4d00 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 03:18:23 +0000
Subject: [PATCH 147/194] restructure README: expand getting started, demote
Docker
---
README.md | 109 +++++++++++++++++++++++-------------------------------
1 file changed, 46 insertions(+), 63 deletions(-)
diff --git a/README.md b/README.md
index eca20ee..2405ee0 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,8 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- [About this project](#about-this-project)
- [Features](#features)
- [Performance & Scale](#performance--scale)
-- [Docker](#docker)
-- [Data storage](#data-storage)
- [Getting started](#getting-started)
+- [Data storage](#data-storage)
- [Remote gateway](#remote-gateway)
- [How it works](#how-it-works)
- [Known rough edges](#known-rough-edges)
@@ -71,41 +70,60 @@ propagate to subscribers.
- Paginated at 10,000 pages per request
- Use `?batch=N` to export in chunks: `/export?batch=0`, `/export?batch=1`, etc.
-## Docker
+## Getting started
-TinyWeb is distributed as source. Clone the repo, then build and run with Docker Compose:
+**Requirements:** Python 3.10+ and pip.
```bash
git clone https://codeberg.org/tinyweb/tinyweb.git
cd tinyweb
+pip install -r requirements.txt
+python app.py
+```
+
+Open `http://127.0.0.1:8080` in your browser. The web UI is localhost-only by default.
+
+Your destination hash is printed on startup — share it with friends so they can subscribe to your index.
+
+### Command line options
+
+```bash
+python app.py -p 9000 # Use port 9000 instead of default 8080
+python app.py --bind 0.0.0.0 # Expose to your LAN (no auth — see Security)
+```
+
+### Keeping it running
+
+```bash
+# Terminal session (closes when you log out):
+python app.py
+
+# Background with tmux:
+tmux new-session -d -s tinyweb 'python app.py'
+
+# Background with nohup:
+nohup python app.py &
+```
+
+### Forum plugin (optional)
+
+```bash
+pip install tinyweb-forum
+```
+
+Enable the forum on the `/style` page. See the [tinyweb-forum README](https://codeberg.org/tinyweb/tinyweb-forum) for details.
+
+### Docker
+
+A `docker-compose.yml` is included for containerized setups. Build and run:
+
+```bash
docker compose up -d
```
-The bundled `docker-compose.yml` builds the image from source and persists your data in a named volume:
+Data persists in the `tinyweb-data` named volume. On Linux with LAN auto-discovery it works as-is; on macOS or remote setups, see `docker-compose.yml` comments for TCP transport config.
-```yaml
-services:
- tinyweb:
- build: .
- ports:
- - "8080:8080"
- volumes:
- - tinyweb-data:/data
- restart: unless-stopped
-
-volumes:
- tinyweb-data:
-```
-
-After the first build, the image is cached locally and subsequent `docker compose up -d` calls are instant. To update to the latest source:
-
-```bash
-git pull && docker compose up -d --build
-```
-
-If you're on macOS or need to reach a Reticulum node over TCP, uncomment the `RNS_TCP_HOST` / `RNS_TCP_PORT` block in `docker-compose.yml` and point it at a host running Reticulum. On Linux with LAN auto-discovery, leave it as-is (or switch to `network_mode: host`).
-
-### Storage Estimates
+## Storage Estimates
Average web page content is ~15KB per page:
@@ -148,41 +166,6 @@ Back up the whole `~/.tinyweb/` directory periodically. The two files that matte
The `/export` page produces a JSON dump of your pages. It's a migration aid — it doesn't preserve your identity file, your custom template, or subscription state. A full restore needs a copy of `~/.tinyweb/`.
-### Docker
-
-When you run via `docker compose up` (above), data is stored in the `tinyweb-data` named volume and persists across rebuilds. To inspect or back up:
-
-```bash
-docker compose exec tinyweb ls -la /data
-docker compose down # stop without removing the volume
-```
-
-To reset everything (destroys your index and identity — back up first):
-
-```bash
-docker compose down -v
-```
-
-### Command line options
-
-```bash
-python app.py -p 9000 # Use port 9000 instead of default 8080
-python app.py --bind 0.0.0.0 # Expose the web UI to your LAN (see warning below)
-```
-
-By default, the web UI binds to `127.0.0.1` and is only reachable from the machine running TinyWeb. **The UI has no authentication** — anyone who can reach the port can read, add, and delete entries, and change settings. Only pass `--bind 0.0.0.0` if you fully trust your network, or put TinyWeb behind an authenticating reverse proxy.
-
-## Getting started
-
-```bash
-pip install -r requirements.txt
-python app.py
-```
-
-This starts the Reticulum server and an HTTP gateway on `http://127.0.0.1:8080`. Open it in your browser. The UI is localhost-only by default; see `--bind` under *Command line options* if you want to reach it from another machine.
-
-Your destination hash is printed on startup — share it with friends so they can subscribe to your index.
-
## Remote gateway
To browse a remote TinyWeb instance without running your own index:
From 6c4b26781da0b6519f5383f16249a92513f99c32 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 03:18:23 +0000
Subject: [PATCH 148/194] restructure README: expand getting started, demote
Docker
---
README.md | 109 +++++++++++++++++++++++-------------------------------
1 file changed, 46 insertions(+), 63 deletions(-)
diff --git a/README.md b/README.md
index eca20ee..2405ee0 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,8 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- [About this project](#about-this-project)
- [Features](#features)
- [Performance & Scale](#performance--scale)
-- [Docker](#docker)
-- [Data storage](#data-storage)
- [Getting started](#getting-started)
+- [Data storage](#data-storage)
- [Remote gateway](#remote-gateway)
- [How it works](#how-it-works)
- [Known rough edges](#known-rough-edges)
@@ -71,41 +70,60 @@ propagate to subscribers.
- Paginated at 10,000 pages per request
- Use `?batch=N` to export in chunks: `/export?batch=0`, `/export?batch=1`, etc.
-## Docker
+## Getting started
-TinyWeb is distributed as source. Clone the repo, then build and run with Docker Compose:
+**Requirements:** Python 3.10+ and pip.
```bash
git clone https://codeberg.org/tinyweb/tinyweb.git
cd tinyweb
+pip install -r requirements.txt
+python app.py
+```
+
+Open `http://127.0.0.1:8080` in your browser. The web UI is localhost-only by default.
+
+Your destination hash is printed on startup — share it with friends so they can subscribe to your index.
+
+### Command line options
+
+```bash
+python app.py -p 9000 # Use port 9000 instead of default 8080
+python app.py --bind 0.0.0.0 # Expose to your LAN (no auth — see Security)
+```
+
+### Keeping it running
+
+```bash
+# Terminal session (closes when you log out):
+python app.py
+
+# Background with tmux:
+tmux new-session -d -s tinyweb 'python app.py'
+
+# Background with nohup:
+nohup python app.py &
+```
+
+### Forum plugin (optional)
+
+```bash
+pip install tinyweb-forum
+```
+
+Enable the forum on the `/style` page. See the [tinyweb-forum README](https://codeberg.org/tinyweb/tinyweb-forum) for details.
+
+### Docker
+
+A `docker-compose.yml` is included for containerized setups. Build and run:
+
+```bash
docker compose up -d
```
-The bundled `docker-compose.yml` builds the image from source and persists your data in a named volume:
+Data persists in the `tinyweb-data` named volume. On Linux with LAN auto-discovery it works as-is; on macOS or remote setups, see `docker-compose.yml` comments for TCP transport config.
-```yaml
-services:
- tinyweb:
- build: .
- ports:
- - "8080:8080"
- volumes:
- - tinyweb-data:/data
- restart: unless-stopped
-
-volumes:
- tinyweb-data:
-```
-
-After the first build, the image is cached locally and subsequent `docker compose up -d` calls are instant. To update to the latest source:
-
-```bash
-git pull && docker compose up -d --build
-```
-
-If you're on macOS or need to reach a Reticulum node over TCP, uncomment the `RNS_TCP_HOST` / `RNS_TCP_PORT` block in `docker-compose.yml` and point it at a host running Reticulum. On Linux with LAN auto-discovery, leave it as-is (or switch to `network_mode: host`).
-
-### Storage Estimates
+## Storage Estimates
Average web page content is ~15KB per page:
@@ -148,41 +166,6 @@ Back up the whole `~/.tinyweb/` directory periodically. The two files that matte
The `/export` page produces a JSON dump of your pages. It's a migration aid — it doesn't preserve your identity file, your custom template, or subscription state. A full restore needs a copy of `~/.tinyweb/`.
-### Docker
-
-When you run via `docker compose up` (above), data is stored in the `tinyweb-data` named volume and persists across rebuilds. To inspect or back up:
-
-```bash
-docker compose exec tinyweb ls -la /data
-docker compose down # stop without removing the volume
-```
-
-To reset everything (destroys your index and identity — back up first):
-
-```bash
-docker compose down -v
-```
-
-### Command line options
-
-```bash
-python app.py -p 9000 # Use port 9000 instead of default 8080
-python app.py --bind 0.0.0.0 # Expose the web UI to your LAN (see warning below)
-```
-
-By default, the web UI binds to `127.0.0.1` and is only reachable from the machine running TinyWeb. **The UI has no authentication** — anyone who can reach the port can read, add, and delete entries, and change settings. Only pass `--bind 0.0.0.0` if you fully trust your network, or put TinyWeb behind an authenticating reverse proxy.
-
-## Getting started
-
-```bash
-pip install -r requirements.txt
-python app.py
-```
-
-This starts the Reticulum server and an HTTP gateway on `http://127.0.0.1:8080`. Open it in your browser. The UI is localhost-only by default; see `--bind` under *Command line options* if you want to reach it from another machine.
-
-Your destination hash is printed on startup — share it with friends so they can subscribe to your index.
-
## Remote gateway
To browse a remote TinyWeb instance without running your own index:
From 91eec45bbb38f19b69c96a0ab6ea401e4e812fa2 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 05:35:54 +0000
Subject: [PATCH 149/194] search: match by tag; add: handle ssl errors with
manual entry
---
handlers/pages.py | 2 +-
handlers/search.py | 17 +++++++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/handlers/pages.py b/handlers/pages.py
index 3e213e0..cd3efba 100644
--- a/handlers/pages.py
+++ b/handlers/pages.py
@@ -83,7 +83,7 @@ def handle_add_submit(body):
except Exception as e:
error_msg = str(e).lower()
- if "block" in error_msg or "cloudflare" in error_msg or "403" in error_msg:
+ if any(x in error_msg for x in ("block", "cloudflare", "403", "ssl", "handshake", "max retries", "timeout", "connection")):
return _respond(
f"add url (manual entry) "
f"{esc(url)} blocks automated access. "
diff --git a/handlers/search.py b/handlers/search.py
index 967b59c..d9ee7c1 100644
--- a/handlers/search.py
+++ b/handlers/search.py
@@ -41,6 +41,23 @@ def handle_search(query):
else:
fused_ids = bm25_ids
+ # Also match by tag
+ search_terms = [w.lower() for w in q.split() if w]
+ if search_terms:
+ placeholders = ",".join("?" * len(search_terms))
+ tag_rows = db.execute(
+ f"SELECT DISTINCT pt.page_id FROM page_tags pt "
+ f"JOIN tags t ON t.id = pt.tag_id "
+ f"WHERE LOWER(t.name) IN ({placeholders})",
+ search_terms,
+ ).fetchall()
+ tag_ids = {r["page_id"] for r in tag_rows}
+ seen = set(fused_ids)
+ for pid in tag_ids:
+ if pid not in seen:
+ fused_ids.append(pid)
+ seen.add(pid)
+
total_results = len(fused_ids)
page_ids = fused_ids[offset:offset + PER_PAGE]
From 8e68d02413fea7c96e4da7d53c432a89538901d8 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 05:35:54 +0000
Subject: [PATCH 150/194] search: match by tag; add: handle ssl errors with
manual entry
---
handlers/pages.py | 2 +-
handlers/search.py | 17 +++++++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/handlers/pages.py b/handlers/pages.py
index 3e213e0..cd3efba 100644
--- a/handlers/pages.py
+++ b/handlers/pages.py
@@ -83,7 +83,7 @@ def handle_add_submit(body):
except Exception as e:
error_msg = str(e).lower()
- if "block" in error_msg or "cloudflare" in error_msg or "403" in error_msg:
+ if any(x in error_msg for x in ("block", "cloudflare", "403", "ssl", "handshake", "max retries", "timeout", "connection")):
return _respond(
f"add url (manual entry) "
f"{esc(url)} blocks automated access. "
diff --git a/handlers/search.py b/handlers/search.py
index 967b59c..d9ee7c1 100644
--- a/handlers/search.py
+++ b/handlers/search.py
@@ -41,6 +41,23 @@ def handle_search(query):
else:
fused_ids = bm25_ids
+ # Also match by tag
+ search_terms = [w.lower() for w in q.split() if w]
+ if search_terms:
+ placeholders = ",".join("?" * len(search_terms))
+ tag_rows = db.execute(
+ f"SELECT DISTINCT pt.page_id FROM page_tags pt "
+ f"JOIN tags t ON t.id = pt.tag_id "
+ f"WHERE LOWER(t.name) IN ({placeholders})",
+ search_terms,
+ ).fetchall()
+ tag_ids = {r["page_id"] for r in tag_rows}
+ seen = set(fused_ids)
+ for pid in tag_ids:
+ if pid not in seen:
+ fused_ids.append(pid)
+ seen.add(pid)
+
total_results = len(fused_ids)
page_ids = fused_ids[offset:offset + PER_PAGE]
From 6ecb4802ce6f6be03d7c01eed9c288a879e47097 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 05:58:55 +0000
Subject: [PATCH 151/194] readme: remove duplicate forum section
---
README.md | 8 --------
1 file changed, 8 deletions(-)
diff --git a/README.md b/README.md
index 2405ee0..991c981 100644
--- a/README.md
+++ b/README.md
@@ -105,14 +105,6 @@ tmux new-session -d -s tinyweb 'python app.py'
nohup python app.py &
```
-### Forum plugin (optional)
-
-```bash
-pip install tinyweb-forum
-```
-
-Enable the forum on the `/style` page. See the [tinyweb-forum README](https://codeberg.org/tinyweb/tinyweb-forum) for details.
-
### Docker
A `docker-compose.yml` is included for containerized setups. Build and run:
From e0a5d44d94caad13bbb92f892ed305ed893fd4cd Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 9 Jun 2026 05:58:55 +0000
Subject: [PATCH 152/194] readme: remove duplicate forum section
---
README.md | 8 --------
1 file changed, 8 deletions(-)
diff --git a/README.md b/README.md
index 2405ee0..991c981 100644
--- a/README.md
+++ b/README.md
@@ -105,14 +105,6 @@ tmux new-session -d -s tinyweb 'python app.py'
nohup python app.py &
```
-### Forum plugin (optional)
-
-```bash
-pip install tinyweb-forum
-```
-
-Enable the forum on the `/style` page. See the [tinyweb-forum README](https://codeberg.org/tinyweb/tinyweb-forum) for details.
-
### Docker
A `docker-compose.yml` is included for containerized setups. Build and run:
From 93686d71c38235f7ea9f6bb524315eb42b144730 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:32:58 +0000
Subject: [PATCH 153/194] simplify: move subscribe form to GET
/subscriptions/add
---
handlers/__init__.py | 8 +++-----
handlers/subscriptions.py | 2 +-
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 95d1753..f05dabb 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -59,12 +59,8 @@ def _dispatch_inner(data):
if path == "/":
return handle_search(query)
elif path == "/add":
- action_type = query.get("type", ["index"])[0]
prefill_url = query.get("url", [""])[0].strip()
- return handle_add_form(
- action_type=action_type if action_type == "subscribe" else "index",
- prefill_url=prefill_url,
- )
+ return handle_add_form(prefill_url=prefill_url)
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
@@ -96,6 +92,8 @@ def _dispatch_inner(data):
return handle_api_sites(query)
elif path == "/subscriptions":
return handle_subscriptions()
+ elif path == "/subscriptions/add":
+ return handle_add_form(action_type="subscribe")
elif path.startswith("/subscriptions/browse/"):
sid = extract_id("/subscriptions/browse/")
return handle_subscription_browse(sid) if sid is not None else _error(400)
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
index b54a253..e0ecf68 100644
--- a/handlers/subscriptions.py
+++ b/handlers/subscriptions.py
@@ -198,7 +198,7 @@ def handle_subscriptions(msg=""):
f' '
f'subscribe '
f''
- f'or subscribe to an instance
'
+ f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
f'back '
From 25e24efbfa03a0c46b1c23f9a001b7c415e8c160 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:32:58 +0000
Subject: [PATCH 154/194] simplify: move subscribe form to GET
/subscriptions/add
---
handlers/__init__.py | 8 +++-----
handlers/subscriptions.py | 2 +-
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 95d1753..f05dabb 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -59,12 +59,8 @@ def _dispatch_inner(data):
if path == "/":
return handle_search(query)
elif path == "/add":
- action_type = query.get("type", ["index"])[0]
prefill_url = query.get("url", [""])[0].strip()
- return handle_add_form(
- action_type=action_type if action_type == "subscribe" else "index",
- prefill_url=prefill_url,
- )
+ return handle_add_form(prefill_url=prefill_url)
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
@@ -96,6 +92,8 @@ def _dispatch_inner(data):
return handle_api_sites(query)
elif path == "/subscriptions":
return handle_subscriptions()
+ elif path == "/subscriptions/add":
+ return handle_add_form(action_type="subscribe")
elif path.startswith("/subscriptions/browse/"):
sid = extract_id("/subscriptions/browse/")
return handle_subscription_browse(sid) if sid is not None else _error(400)
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
index b54a253..e0ecf68 100644
--- a/handlers/subscriptions.py
+++ b/handlers/subscriptions.py
@@ -198,7 +198,7 @@ def handle_subscriptions(msg=""):
f' '
f'subscribe '
f''
- f'or subscribe to an instance
'
+ f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
f'back '
From f61278f8b89fbc1a491a7aa3192600cf40b7d1e7 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:33:38 +0000
Subject: [PATCH 155/194] Revert "simplify: move subscribe form to GET
/subscriptions/add"
This reverts commit 25e24efbfa03a0c46b1c23f9a001b7c415e8c160.
---
handlers/__init__.py | 8 +++++---
handlers/subscriptions.py | 2 +-
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index f05dabb..95d1753 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -59,8 +59,12 @@ def _dispatch_inner(data):
if path == "/":
return handle_search(query)
elif path == "/add":
+ action_type = query.get("type", ["index"])[0]
prefill_url = query.get("url", [""])[0].strip()
- return handle_add_form(prefill_url=prefill_url)
+ return handle_add_form(
+ action_type=action_type if action_type == "subscribe" else "index",
+ prefill_url=prefill_url,
+ )
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
@@ -92,8 +96,6 @@ def _dispatch_inner(data):
return handle_api_sites(query)
elif path == "/subscriptions":
return handle_subscriptions()
- elif path == "/subscriptions/add":
- return handle_add_form(action_type="subscribe")
elif path.startswith("/subscriptions/browse/"):
sid = extract_id("/subscriptions/browse/")
return handle_subscription_browse(sid) if sid is not None else _error(400)
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
index e0ecf68..b54a253 100644
--- a/handlers/subscriptions.py
+++ b/handlers/subscriptions.py
@@ -198,7 +198,7 @@ def handle_subscriptions(msg=""):
f' '
f'subscribe '
f''
- f'or subscribe to an instance
'
+ f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
f'back '
From 0da46d29fc50f02eda0b863106a81b5ff3e5bf65 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:33:38 +0000
Subject: [PATCH 156/194] Revert "simplify: move subscribe form to GET
/subscriptions/add"
This reverts commit 25e24efbfa03a0c46b1c23f9a001b7c415e8c160.
---
handlers/__init__.py | 8 +++++---
handlers/subscriptions.py | 2 +-
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index f05dabb..95d1753 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -59,8 +59,12 @@ def _dispatch_inner(data):
if path == "/":
return handle_search(query)
elif path == "/add":
+ action_type = query.get("type", ["index"])[0]
prefill_url = query.get("url", [""])[0].strip()
- return handle_add_form(prefill_url=prefill_url)
+ return handle_add_form(
+ action_type=action_type if action_type == "subscribe" else "index",
+ prefill_url=prefill_url,
+ )
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
@@ -92,8 +96,6 @@ def _dispatch_inner(data):
return handle_api_sites(query)
elif path == "/subscriptions":
return handle_subscriptions()
- elif path == "/subscriptions/add":
- return handle_add_form(action_type="subscribe")
elif path.startswith("/subscriptions/browse/"):
sid = extract_id("/subscriptions/browse/")
return handle_subscription_browse(sid) if sid is not None else _error(400)
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
index e0ecf68..b54a253 100644
--- a/handlers/subscriptions.py
+++ b/handlers/subscriptions.py
@@ -198,7 +198,7 @@ def handle_subscriptions(msg=""):
f' '
f'subscribe '
f''
- f'or subscribe to an instance
'
+ f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
f'back '
From 810fd701f0f168ae7b4e97708cafe3db4fd01c89 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:36:37 +0000
Subject: [PATCH 157/194] simplify: move subscribe form to GET
/subscriptions/add
---
handlers/__init__.py | 8 +++-----
handlers/subscriptions.py | 2 +-
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 95d1753..f05dabb 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -59,12 +59,8 @@ def _dispatch_inner(data):
if path == "/":
return handle_search(query)
elif path == "/add":
- action_type = query.get("type", ["index"])[0]
prefill_url = query.get("url", [""])[0].strip()
- return handle_add_form(
- action_type=action_type if action_type == "subscribe" else "index",
- prefill_url=prefill_url,
- )
+ return handle_add_form(prefill_url=prefill_url)
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
@@ -96,6 +92,8 @@ def _dispatch_inner(data):
return handle_api_sites(query)
elif path == "/subscriptions":
return handle_subscriptions()
+ elif path == "/subscriptions/add":
+ return handle_add_form(action_type="subscribe")
elif path.startswith("/subscriptions/browse/"):
sid = extract_id("/subscriptions/browse/")
return handle_subscription_browse(sid) if sid is not None else _error(400)
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
index b54a253..e0ecf68 100644
--- a/handlers/subscriptions.py
+++ b/handlers/subscriptions.py
@@ -198,7 +198,7 @@ def handle_subscriptions(msg=""):
f' '
f'subscribe '
f''
- f'or subscribe to an instance
'
+ f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
f'back '
From 4a45700067d2112e3f616f5e0d91227aa20ab8d2 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:36:37 +0000
Subject: [PATCH 158/194] simplify: move subscribe form to GET
/subscriptions/add
---
handlers/__init__.py | 8 +++-----
handlers/subscriptions.py | 2 +-
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 95d1753..f05dabb 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -59,12 +59,8 @@ def _dispatch_inner(data):
if path == "/":
return handle_search(query)
elif path == "/add":
- action_type = query.get("type", ["index"])[0]
prefill_url = query.get("url", [""])[0].strip()
- return handle_add_form(
- action_type=action_type if action_type == "subscribe" else "index",
- prefill_url=prefill_url,
- )
+ return handle_add_form(prefill_url=prefill_url)
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
@@ -96,6 +92,8 @@ def _dispatch_inner(data):
return handle_api_sites(query)
elif path == "/subscriptions":
return handle_subscriptions()
+ elif path == "/subscriptions/add":
+ return handle_add_form(action_type="subscribe")
elif path.startswith("/subscriptions/browse/"):
sid = extract_id("/subscriptions/browse/")
return handle_subscription_browse(sid) if sid is not None else _error(400)
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
index b54a253..e0ecf68 100644
--- a/handlers/subscriptions.py
+++ b/handlers/subscriptions.py
@@ -198,7 +198,7 @@ def handle_subscriptions(msg=""):
f' '
f'subscribe '
f''
- f'or subscribe to an instance
'
+ f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
f'back '
From a6bde0aa87d843d884d0295537a327a9108fbb4e Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:43:26 +0000
Subject: [PATCH 159/194] bookmarklet: use dynamic host and scheme from request
headers
---
gateway.py | 1 +
handlers/__init__.py | 9 +++++----
handlers/customize.py | 12 +++++++-----
3 files changed, 13 insertions(+), 9 deletions(-)
diff --git a/gateway.py b/gateway.py
index 7d3fd53..02a2d95 100644
--- a/gateway.py
+++ b/gateway.py
@@ -125,6 +125,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
"body": body,
"cookies": cookies,
"gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"),
+ "scheme": self.headers.get("X-Forwarded-Proto", "http"),
}
try:
diff --git a/handlers/__init__.py b/handlers/__init__.py
index f05dabb..ba91d9a 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -48,6 +48,7 @@ def _dispatch_inner(data):
query = data.get("query", {})
body = data.get("body", {})
gateway_host = data.get("gateway_host", "")
+ scheme = data.get("scheme", "http")
def extract_id(prefix):
try:
@@ -72,7 +73,7 @@ def _dispatch_inner(data):
elif path == "/bookmark":
return handle_bookmark(query)
elif path == "/style":
- return handle_style_form()
+ return handle_style_form(gateway_host=gateway_host, scheme=scheme)
elif path == "/share/preview":
return handle_share_preview()
elif path == "/about":
@@ -121,14 +122,14 @@ def _dispatch_inner(data):
pid = extract_id("/delete/")
return handle_delete(pid) if pid is not None else _error(400)
elif path == "/style":
- return handle_style_submit(body)
+ return handle_style_submit(body, gateway_host=gateway_host, scheme=scheme)
elif path == "/style/reset":
set_setting("custom_template", "")
- return handle_style_form("Template reset to default.")
+ return handle_style_form("Template reset to default.", gateway_host=gateway_host, scheme=scheme)
elif path == "/style/vacuum":
from db import vacuum_db
vacuum_db()
- return handle_style_form("Database vacuumed.")
+ return handle_style_form("Database vacuumed.", gateway_host=gateway_host, scheme=scheme)
elif path == "/import":
return handle_import_submit(body)
elif path == "/reindex":
diff --git a/handlers/customize.py b/handlers/customize.py
index 1f2568b..6e158ed 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -5,7 +5,7 @@ from ._helpers import _respond, _csrf_field, _get_bookmark_token
from .subscriptions import _count_shared_pages
-def handle_style_form(msg=""):
+def handle_style_form(msg="", gateway_host="", scheme="http"):
template = get_setting("custom_template") or DEFAULT_TEMPLATE
name = get_site_name()
sharing = get_setting("sharing_enabled", "0")
@@ -135,7 +135,7 @@ def handle_style_form(msg=""):
f""
f"bookmarklet "
f"Drag this link to your bookmarks bar. Click it on any page to index it instantly.
"
- f'+ save to {esc(name)}
'
+ f'r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}
'
f"reset "
f''
@@ -153,7 +153,7 @@ def handle_style_form(msg=""):
)
-def handle_style_submit(body):
+def handle_style_submit(body, gateway_host="", scheme="http"):
template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
name = body.get("site_name", ["tinyweb"])[0].strip()
sharing = "1" if body.get("sharing_enabled") else "0"
@@ -192,7 +192,8 @@ def handle_style_submit(body):
from handlers import forum_plugin
if forum_enabled == "1" and forum_plugin is None:
return handle_style_form(
- "Forum plugin not installed. Run: pip install tinyweb-forum"
+ "Forum plugin not installed. Run: pip install tinyweb-forum",
+ gateway_host=gateway_host, scheme=scheme,
)
if forum_enabled == "1":
forum_plugin.enable()
@@ -208,7 +209,8 @@ def handle_style_submit(body):
pass
set_setting("forum_enabled", forum_enabled)
templates_mod.FORUM_ENABLED = (forum_enabled == "1")
- return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.")
+ return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.",
+ gateway_host=gateway_host, scheme=scheme)
def handle_about():
From 27147bd3f966e8849a03a9d8bdc29cccc0d7c090 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:43:26 +0000
Subject: [PATCH 160/194] bookmarklet: use dynamic host and scheme from request
headers
---
gateway.py | 1 +
handlers/__init__.py | 9 +++++----
handlers/customize.py | 12 +++++++-----
3 files changed, 13 insertions(+), 9 deletions(-)
diff --git a/gateway.py b/gateway.py
index 7d3fd53..02a2d95 100644
--- a/gateway.py
+++ b/gateway.py
@@ -125,6 +125,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
"body": body,
"cookies": cookies,
"gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"),
+ "scheme": self.headers.get("X-Forwarded-Proto", "http"),
}
try:
diff --git a/handlers/__init__.py b/handlers/__init__.py
index f05dabb..ba91d9a 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -48,6 +48,7 @@ def _dispatch_inner(data):
query = data.get("query", {})
body = data.get("body", {})
gateway_host = data.get("gateway_host", "")
+ scheme = data.get("scheme", "http")
def extract_id(prefix):
try:
@@ -72,7 +73,7 @@ def _dispatch_inner(data):
elif path == "/bookmark":
return handle_bookmark(query)
elif path == "/style":
- return handle_style_form()
+ return handle_style_form(gateway_host=gateway_host, scheme=scheme)
elif path == "/share/preview":
return handle_share_preview()
elif path == "/about":
@@ -121,14 +122,14 @@ def _dispatch_inner(data):
pid = extract_id("/delete/")
return handle_delete(pid) if pid is not None else _error(400)
elif path == "/style":
- return handle_style_submit(body)
+ return handle_style_submit(body, gateway_host=gateway_host, scheme=scheme)
elif path == "/style/reset":
set_setting("custom_template", "")
- return handle_style_form("Template reset to default.")
+ return handle_style_form("Template reset to default.", gateway_host=gateway_host, scheme=scheme)
elif path == "/style/vacuum":
from db import vacuum_db
vacuum_db()
- return handle_style_form("Database vacuumed.")
+ return handle_style_form("Database vacuumed.", gateway_host=gateway_host, scheme=scheme)
elif path == "/import":
return handle_import_submit(body)
elif path == "/reindex":
diff --git a/handlers/customize.py b/handlers/customize.py
index 1f2568b..6e158ed 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -5,7 +5,7 @@ from ._helpers import _respond, _csrf_field, _get_bookmark_token
from .subscriptions import _count_shared_pages
-def handle_style_form(msg=""):
+def handle_style_form(msg="", gateway_host="", scheme="http"):
template = get_setting("custom_template") or DEFAULT_TEMPLATE
name = get_site_name()
sharing = get_setting("sharing_enabled", "0")
@@ -135,7 +135,7 @@ def handle_style_form(msg=""):
f" "
f"bookmarklet "
f"Drag this link to your bookmarks bar. Click it on any page to index it instantly.
"
- f'+ save to {esc(name)}
'
+ f'r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}
'
f"reset "
f''
@@ -153,7 +153,7 @@ def handle_style_form(msg=""):
)
-def handle_style_submit(body):
+def handle_style_submit(body, gateway_host="", scheme="http"):
template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
name = body.get("site_name", ["tinyweb"])[0].strip()
sharing = "1" if body.get("sharing_enabled") else "0"
@@ -192,7 +192,8 @@ def handle_style_submit(body):
from handlers import forum_plugin
if forum_enabled == "1" and forum_plugin is None:
return handle_style_form(
- "Forum plugin not installed. Run: pip install tinyweb-forum"
+ "Forum plugin not installed. Run: pip install tinyweb-forum",
+ gateway_host=gateway_host, scheme=scheme,
)
if forum_enabled == "1":
forum_plugin.enable()
@@ -208,7 +209,8 @@ def handle_style_submit(body):
pass
set_setting("forum_enabled", forum_enabled)
templates_mod.FORUM_ENABLED = (forum_enabled == "1")
- return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.")
+ return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.",
+ gateway_host=gateway_host, scheme=scheme)
def handle_about():
From 0f3f8490c44cc705879cc1bc9938903b6f59688f Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:48:34 +0000
Subject: [PATCH 161/194] docker: fix data persistence with TINYWEB_DATA_DIR
env var
---
Dockerfile | 4 +---
app.py | 2 +-
db.py | 2 +-
entrypoint.sh | 1 +
4 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 713df67..3f73263 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,9 +13,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
-RUN mkdir -p /data \
- && ln -sf /data/index.db index.db \
- && ln -sf /data/tinyweb_identity tinyweb_identity
+RUN mkdir -p /data
ENV PYTHONUNBUFFERED=1
diff --git a/app.py b/app.py
index 6e70196..035eca0 100644
--- a/app.py
+++ b/app.py
@@ -16,7 +16,7 @@ from gateway import GatewayState, GatewayHandler
IDENTITY_FILE = "tinyweb_identity"
DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
DEFAULT_TRANSPORT_PORT = 4242
-DATA_DIR = os.path.expanduser("~/.tinyweb")
+DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb")
def get_transport_config():
diff --git a/db.py b/db.py
index ec13254..97378d9 100644
--- a/db.py
+++ b/db.py
@@ -6,7 +6,7 @@ import os
from urllib.parse import urlparse, urljoin, parse_qs, urlencode, urlunparse, quote
from bs4 import BeautifulSoup
-DATA_DIR = os.path.expanduser("~/.tinyweb")
+DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb")
DATABASE = os.path.join(DATA_DIR, "index.db")
BLOCKED_NETWORKS = [
diff --git a/entrypoint.sh b/entrypoint.sh
index 7385146..b741c4c 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -29,6 +29,7 @@ if [ ! -f "$CONFIG_FILE" ]; then
EOF
fi
+export TINYWEB_DATA_DIR="/data"
export RNS_CONFIG_DIR="$CONFIG_DIR"
# Bind to 0.0.0.0 inside the container; isolation is handled by Docker's port mapping.
exec python app.py --bind 0.0.0.0 "$@"
From 1461c62c9151b1c4e3f8ee105ae7487a07b52c01 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 07:48:34 +0000
Subject: [PATCH 162/194] docker: fix data persistence with TINYWEB_DATA_DIR
env var
---
Dockerfile | 4 +---
app.py | 2 +-
db.py | 2 +-
entrypoint.sh | 1 +
4 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 713df67..3f73263 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,9 +13,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
-RUN mkdir -p /data \
- && ln -sf /data/index.db index.db \
- && ln -sf /data/tinyweb_identity tinyweb_identity
+RUN mkdir -p /data
ENV PYTHONUNBUFFERED=1
diff --git a/app.py b/app.py
index 6e70196..035eca0 100644
--- a/app.py
+++ b/app.py
@@ -16,7 +16,7 @@ from gateway import GatewayState, GatewayHandler
IDENTITY_FILE = "tinyweb_identity"
DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
DEFAULT_TRANSPORT_PORT = 4242
-DATA_DIR = os.path.expanduser("~/.tinyweb")
+DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb")
def get_transport_config():
diff --git a/db.py b/db.py
index ec13254..97378d9 100644
--- a/db.py
+++ b/db.py
@@ -6,7 +6,7 @@ import os
from urllib.parse import urlparse, urljoin, parse_qs, urlencode, urlunparse, quote
from bs4 import BeautifulSoup
-DATA_DIR = os.path.expanduser("~/.tinyweb")
+DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb")
DATABASE = os.path.join(DATA_DIR, "index.db")
BLOCKED_NETWORKS = [
diff --git a/entrypoint.sh b/entrypoint.sh
index 7385146..b741c4c 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -29,6 +29,7 @@ if [ ! -f "$CONFIG_FILE" ]; then
EOF
fi
+export TINYWEB_DATA_DIR="/data"
export RNS_CONFIG_DIR="$CONFIG_DIR"
# Bind to 0.0.0.0 inside the container; isolation is handled by Docker's port mapping.
exec python app.py --bind 0.0.0.0 "$@"
From d0c2c25a22b59f453af31013313a350fe2f989e0 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:00:57 +0000
Subject: [PATCH 163/194] template: add {{nav}} placeholder, separate from
{{content}}
---
templates.py | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/templates.py b/templates.py
index 777ed63..e0edb19 100644
--- a/templates.py
+++ b/templates.py
@@ -34,21 +34,25 @@ ul + .forum-form { margin-top: 1rem; }
.section ul { margin: 0.3rem 0; }
"""
-DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{content}}\n\n"
-
-
-def _default_template():
+def _nav_html():
name = esc(get_setting("site_name", "tinyweb"))
forum_link = ' | forum ' if FORUM_ENABLED else ""
return (
- '\n\n \n \n'
- f'{FORUM_CSS}\n\n'
f'{name} '
' | search | browse '
' | tags | subscriptions '
f'{forum_link}'
' | customize | about
\n'
- " \n{{content}}\n\n"
+ " \n"
+ )
+
+DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{nav}}{{content}}\n\n"
+
+
+def _default_template():
+ return (
+ '\n\n \n \n'
+ + FORUM_CSS + '\n\n{{nav}}{{content}}\n\n'
)
@@ -62,6 +66,7 @@ def wrap_page(body_html, use_default=False):
forum_link = ' forum ' if FORUM_ENABLED else ""
template = template.replace("{{forum_link}}", forum_link)
template = template.replace("{{site_name}}", esc(get_setting("site_name", "tinyweb")))
+ template = template.replace("{{nav}}", _nav_html())
# Inject forum layout CSS into for any template
head_end = ""
if head_end in template and FORUM_CSS not in template:
From 501d9e1838f5c2ff9d7902ac7483f4f9c12d94ad Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:00:57 +0000
Subject: [PATCH 164/194] template: add {{nav}} placeholder, separate from
{{content}}
---
templates.py | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/templates.py b/templates.py
index 777ed63..e0edb19 100644
--- a/templates.py
+++ b/templates.py
@@ -34,21 +34,25 @@ ul + .forum-form { margin-top: 1rem; }
.section ul { margin: 0.3rem 0; }
"""
-DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{content}}\n\n"
-
-
-def _default_template():
+def _nav_html():
name = esc(get_setting("site_name", "tinyweb"))
forum_link = ' | forum ' if FORUM_ENABLED else ""
return (
- '\n\n \n \n'
- f'{FORUM_CSS}\n\n'
f'{name} '
' | search | browse '
' | tags | subscriptions '
f'{forum_link}'
' | customize | about
\n'
- " \n{{content}}\n\n"
+ " \n"
+ )
+
+DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{nav}}{{content}}\n\n"
+
+
+def _default_template():
+ return (
+ '\n\n \n \n'
+ + FORUM_CSS + '\n\n{{nav}}{{content}}\n\n'
)
@@ -62,6 +66,7 @@ def wrap_page(body_html, use_default=False):
forum_link = ' forum ' if FORUM_ENABLED else ""
template = template.replace("{{forum_link}}", forum_link)
template = template.replace("{{site_name}}", esc(get_setting("site_name", "tinyweb")))
+ template = template.replace("{{nav}}", _nav_html())
# Inject forum layout CSS into for any template
head_end = ""
if head_end in template and FORUM_CSS not in template:
From bef95fd9018118b0b138fa159ab4628790215024 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:05:20 +0000
Subject: [PATCH 165/194] template: remove forum css from DEFAULT_TEMPLATE
(injected at render time)
---
templates.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/templates.py b/templates.py
index e0edb19..89dadd7 100644
--- a/templates.py
+++ b/templates.py
@@ -46,13 +46,13 @@ def _nav_html():
" \n"
)
-DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{nav}}{{content}}\n\n"
+DEFAULT_TEMPLATE = "\n\n \n \n\n\n{{nav}}{{content}}\n\n"
def _default_template():
return (
'\n\n \n \n'
- + FORUM_CSS + '\n\n{{nav}}{{content}}\n\n'
+ '\n\n{{nav}}{{content}}\n\n'
)
From d97ba6d4895691e6f75bb7945b5bca2c420779fe Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:05:20 +0000
Subject: [PATCH 166/194] template: remove forum css from DEFAULT_TEMPLATE
(injected at render time)
---
templates.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/templates.py b/templates.py
index e0edb19..89dadd7 100644
--- a/templates.py
+++ b/templates.py
@@ -46,13 +46,13 @@ def _nav_html():
" \n"
)
-DEFAULT_TEMPLATE = "\n\n \n \n" + FORUM_CSS + "\n\n{{nav}}{{content}}\n\n"
+DEFAULT_TEMPLATE = "\n\n \n \n\n\n{{nav}}{{content}}\n\n"
def _default_template():
return (
'\n\n \n \n'
- + FORUM_CSS + '\n\n{{nav}}{{content}}\n\n'
+ '\n\n{{nav}}{{content}}\n\n'
)
From 56a53e8b082d3c772e858cec34af8b962039ef32 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:12:10 +0000
Subject: [PATCH 167/194] customize: move save button above html textarea, add
unsaved changes indicator
---
handlers/customize.py | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/handlers/customize.py b/handlers/customize.py
index 6e158ed..098f56b 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -127,12 +127,22 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
f'manage semantic index '
f"
"
f"{forum_section}"
+ f'* unsaved changes
'
+ f'save '
+ f''
+ f""
f"custom html "
f"Edit the full page template. Use {esc('{{content}}')} "
f"where page content should appear.
"
- f'{esc(template)} '
- f'save '
- f""
+ f'{esc(template)} '
+ f'Saved with the form above. '
f"bookmarklet "
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)}
'
From 117fc1312c132763ca7b193049b3f2b225b4e706 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:12:10 +0000
Subject: [PATCH 168/194] customize: move save button above html textarea, add
unsaved changes indicator
---
handlers/customize.py | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/handlers/customize.py b/handlers/customize.py
index 6e158ed..098f56b 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -127,12 +127,22 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
f'manage semantic index '
f""
f"{forum_section}"
+ f'* unsaved changes
'
+ f'save '
+ f''
+ f""
f"custom html "
f"Edit the full page template. Use {esc('{{content}}')} "
f"where page content should appear.
"
- f'{esc(template)} '
- f'save '
- f""
+ f'{esc(template)} '
+ f'Saved with the form above. '
f"bookmarklet "
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)}
'
From c77e992768fcb3f4508590accbbde183a2e146e4 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:14:43 +0000
Subject: [PATCH 169/194] customize: split into separate settings and template
forms
---
handlers/__init__.py | 4 +++-
handlers/customize.py | 22 +++++++++++-----------
2 files changed, 14 insertions(+), 12 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index ba91d9a..0b4365e 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -32,7 +32,7 @@ from .subscriptions import (
handle_subscription_delete, handle_subscription_syncall,
_sync_threads,
)
-from .customize import handle_style_form, handle_style_submit, handle_about
+from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_about
from .tags import handle_tags, handle_tag_browse
from .data import (
handle_export, handle_import_form, handle_import_submit,
@@ -123,6 +123,8 @@ def _dispatch_inner(data):
return handle_delete(pid) if pid is not None else _error(400)
elif path == "/style":
return handle_style_submit(body, gateway_host=gateway_host, scheme=scheme)
+ elif path == "/style/template":
+ return handle_style_template_submit(body, gateway_host=gateway_host, scheme=scheme)
elif path == "/style/reset":
set_setting("custom_template", "")
return handle_style_form("Template reset to default.", gateway_host=gateway_host, scheme=scheme)
diff --git a/handlers/customize.py b/handlers/customize.py
index 098f56b..02ad2d2 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -127,22 +127,16 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
f'manage semantic index '
f""
f"{forum_section}"
- f'* unsaved changes
'
- f'save '
- f''
+ f'save settings '
f""
f"custom html "
f"Edit the full page template. Use {esc('{{content}}')} "
f"where page content should appear.
"
+ f''
+ f'{_csrf_field()}'
f'{esc(template)} '
- f'Saved with the form above. '
+ f'save template '
+ f" "
f"bookmarklet "
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)}
'
@@ -223,6 +217,12 @@ def handle_style_submit(body, gateway_host="", scheme="http"):
gateway_host=gateway_host, scheme=scheme)
+def handle_style_template_submit(body, gateway_host="", scheme="http"):
+ template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
+ set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
+ return handle_style_form("Template saved.", gateway_host=gateway_host, scheme=scheme)
+
+
def handle_about():
name = get_site_name()
dest_hash = get_setting("dest_hash")
From 076390cbee6ea09305b8abafd3b16bffcf4562c3 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:14:43 +0000
Subject: [PATCH 170/194] customize: split into separate settings and template
forms
---
handlers/__init__.py | 4 +++-
handlers/customize.py | 22 +++++++++++-----------
2 files changed, 14 insertions(+), 12 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index ba91d9a..0b4365e 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -32,7 +32,7 @@ from .subscriptions import (
handle_subscription_delete, handle_subscription_syncall,
_sync_threads,
)
-from .customize import handle_style_form, handle_style_submit, handle_about
+from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_about
from .tags import handle_tags, handle_tag_browse
from .data import (
handle_export, handle_import_form, handle_import_submit,
@@ -123,6 +123,8 @@ def _dispatch_inner(data):
return handle_delete(pid) if pid is not None else _error(400)
elif path == "/style":
return handle_style_submit(body, gateway_host=gateway_host, scheme=scheme)
+ elif path == "/style/template":
+ return handle_style_template_submit(body, gateway_host=gateway_host, scheme=scheme)
elif path == "/style/reset":
set_setting("custom_template", "")
return handle_style_form("Template reset to default.", gateway_host=gateway_host, scheme=scheme)
diff --git a/handlers/customize.py b/handlers/customize.py
index 098f56b..02ad2d2 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -127,22 +127,16 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
f'manage semantic index '
f""
f"{forum_section}"
- f'* unsaved changes
'
- f'save '
- f''
+ f'save settings '
f""
f"custom html "
f"Edit the full page template. Use {esc('{{content}}')} "
f"where page content should appear.
"
+ f''
+ f'{_csrf_field()}'
f'{esc(template)} '
- f'Saved with the form above. '
+ f'save template '
+ f" "
f"bookmarklet "
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)}
'
@@ -223,6 +217,12 @@ def handle_style_submit(body, gateway_host="", scheme="http"):
gateway_host=gateway_host, scheme=scheme)
+def handle_style_template_submit(body, gateway_host="", scheme="http"):
+ template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
+ set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
+ return handle_style_form("Template saved.", gateway_host=gateway_host, scheme=scheme)
+
+
def handle_about():
name = get_site_name()
dest_hash = get_setting("dest_hash")
From ddab9f62c4aa119f98ef72fbdd19b9d72fef8722 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:46:21 +0000
Subject: [PATCH 171/194] auto-save: settings save on change via /style/field,
noscript fallback button
---
handlers/__init__.py | 4 +++-
handlers/customize.py | 50 ++++++++++++++++++++++++++++++++++++++++---
2 files changed, 50 insertions(+), 4 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 0b4365e..e7eda3e 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -32,7 +32,7 @@ from .subscriptions import (
handle_subscription_delete, handle_subscription_syncall,
_sync_threads,
)
-from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_about
+from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_field_save, handle_about
from .tags import handle_tags, handle_tag_browse
from .data import (
handle_export, handle_import_form, handle_import_submit,
@@ -125,6 +125,8 @@ def _dispatch_inner(data):
return handle_style_submit(body, gateway_host=gateway_host, scheme=scheme)
elif path == "/style/template":
return handle_style_template_submit(body, gateway_host=gateway_host, scheme=scheme)
+ elif path == "/style/field":
+ return handle_field_save(body)
elif path == "/style/reset":
set_setting("custom_template", "")
return handle_style_form("Template reset to default.", gateway_host=gateway_host, scheme=scheme)
diff --git a/handlers/customize.py b/handlers/customize.py
index 02ad2d2..6808046 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -1,7 +1,7 @@
from db import get_db, return_db, get_setting, set_setting, get_site_name
import templates as templates_mod
from templates import esc, DEFAULT_TEMPLATE
-from ._helpers import _respond, _csrf_field, _get_bookmark_token
+from ._helpers import _respond, _json_response, _csrf_field, _get_bookmark_token
from .subscriptions import _count_shared_pages
@@ -54,7 +54,7 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
return _respond(
f"customize "
f"name your search engine "
- f''
+ f' '
f'{_csrf_field()}'
f' '
f"sharing "
@@ -127,8 +127,26 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
f'manage semantic index '
f""
f"{forum_section}"
- f'save settings '
+ f'
'
+ f'save settings '
f" "
+ ''
f"custom html "
f"Edit the full page template. Use {esc('{{content}}')} "
f"where page content should appear.
"
@@ -223,6 +241,32 @@ def handle_style_template_submit(body, gateway_host="", scheme="http"):
return handle_style_form("Template saved.", gateway_host=gateway_host, scheme=scheme)
+def handle_field_save(body):
+ key = body.get("key", [""])[0].strip()
+ value = body.get("value", [""])[0].strip()
+ if not key:
+ return _json_response({"status": "error", "message": "No key provided."}, 400)
+ if key == "forum_enabled":
+ from handlers import forum_plugin
+ if value == "1" and forum_plugin is None:
+ return _json_response({"status": "error", "message": "Forum plugin not installed."}, 400)
+ if value == "1":
+ forum_plugin.enable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "1")
+ except Exception:
+ pass
+ else:
+ forum_plugin.disable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "0")
+ except Exception:
+ pass
+ templates_mod.FORUM_ENABLED = (value == "1")
+ set_setting(key, value)
+ return _json_response({"status": "ok", "message": ""})
+
+
def handle_about():
name = get_site_name()
dest_hash = get_setting("dest_hash")
From a72de2bb102f5afd08da9d78ae96a004dd3736b6 Mon Sep 17 00:00:00 2001
From: blankie
Date: Sun, 14 Jun 2026 08:46:21 +0000
Subject: [PATCH 172/194] auto-save: settings save on change via /style/field,
noscript fallback button
---
handlers/__init__.py | 4 +++-
handlers/customize.py | 50 ++++++++++++++++++++++++++++++++++++++++---
2 files changed, 50 insertions(+), 4 deletions(-)
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 0b4365e..e7eda3e 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -32,7 +32,7 @@ from .subscriptions import (
handle_subscription_delete, handle_subscription_syncall,
_sync_threads,
)
-from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_about
+from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_field_save, handle_about
from .tags import handle_tags, handle_tag_browse
from .data import (
handle_export, handle_import_form, handle_import_submit,
@@ -125,6 +125,8 @@ def _dispatch_inner(data):
return handle_style_submit(body, gateway_host=gateway_host, scheme=scheme)
elif path == "/style/template":
return handle_style_template_submit(body, gateway_host=gateway_host, scheme=scheme)
+ elif path == "/style/field":
+ return handle_field_save(body)
elif path == "/style/reset":
set_setting("custom_template", "")
return handle_style_form("Template reset to default.", gateway_host=gateway_host, scheme=scheme)
diff --git a/handlers/customize.py b/handlers/customize.py
index 02ad2d2..6808046 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -1,7 +1,7 @@
from db import get_db, return_db, get_setting, set_setting, get_site_name
import templates as templates_mod
from templates import esc, DEFAULT_TEMPLATE
-from ._helpers import _respond, _csrf_field, _get_bookmark_token
+from ._helpers import _respond, _json_response, _csrf_field, _get_bookmark_token
from .subscriptions import _count_shared_pages
@@ -54,7 +54,7 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
return _respond(
f"customize "
f"name your search engine "
- f''
+ f' '
f'{_csrf_field()}'
f' '
f"sharing "
@@ -127,8 +127,26 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
f'manage semantic index '
f""
f"{forum_section}"
- f'save settings '
+ f'
'
+ f'save settings '
f" "
+ ''
f"custom html "
f"Edit the full page template. Use {esc('{{content}}')} "
f"where page content should appear.
"
@@ -223,6 +241,32 @@ def handle_style_template_submit(body, gateway_host="", scheme="http"):
return handle_style_form("Template saved.", gateway_host=gateway_host, scheme=scheme)
+def handle_field_save(body):
+ key = body.get("key", [""])[0].strip()
+ value = body.get("value", [""])[0].strip()
+ if not key:
+ return _json_response({"status": "error", "message": "No key provided."}, 400)
+ if key == "forum_enabled":
+ from handlers import forum_plugin
+ if value == "1" and forum_plugin is None:
+ return _json_response({"status": "error", "message": "Forum plugin not installed."}, 400)
+ if value == "1":
+ forum_plugin.enable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "1")
+ except Exception:
+ pass
+ else:
+ forum_plugin.disable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "0")
+ except Exception:
+ pass
+ templates_mod.FORUM_ENABLED = (value == "1")
+ set_setting(key, value)
+ return _json_response({"status": "ok", "message": ""})
+
+
def handle_about():
name = get_site_name()
dest_hash = get_setting("dest_hash")
From 467e6e8e7dbbb410c4dd48afbcad490457b894bb Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 16 Jun 2026 05:11:37 +0000
Subject: [PATCH 173/194] remove demo pages, rename themes, add Content-Length
header
---
gateway.py | 9 +-
handlers/__init__.py | 10 +-
handlers/_helpers.py | 4 +-
handlers/customize.py | 339 +--
handlers/pages.py | 15 +-
handlers/subscriptions.py | 17 +-
templates.py | 35 +-
themes/{tinyweb-site.html => default.html} | 0
themes/kodama.html | 2169 +++++++++++++-------
themes/kodama2.html | 1424 -------------
10 files changed, 1652 insertions(+), 2370 deletions(-)
rename themes/{tinyweb-site.html => default.html} (100%)
delete mode 100644 themes/kodama2.html
diff --git a/gateway.py b/gateway.py
index 02a2d95..fa4b076 100644
--- a/gateway.py
+++ b/gateway.py
@@ -165,12 +165,15 @@ class GatewayHandler(BaseHTTPRequestHandler):
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; "
"img-src 'self' data:")
+ resp_body = resp.get("body", "")
+ encoded = resp_body.encode() if isinstance(resp_body, str) else resp_body
+ if encoded:
+ self.send_header("Content-Length", str(len(encoded)))
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)
+ if encoded:
+ self.wfile.write(encoded)
except ConnectionError as e:
GatewayState.link = None
diff --git a/handlers/__init__.py b/handlers/__init__.py
index e7eda3e..228508e 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -32,7 +32,7 @@ from .subscriptions import (
handle_subscription_delete, handle_subscription_syncall,
_sync_threads,
)
-from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_field_save, handle_about
+from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_field_save, handle_about, _set_flash
from .tags import handle_tags, handle_tag_browse
from .data import (
handle_export, handle_import_form, handle_import_submit,
@@ -66,7 +66,7 @@ def _dispatch_inner(data):
return handle_pages(query)
elif path.startswith("/edit/"):
pid = extract_id("/edit/")
- return handle_edit_form(pid) if pid is not None else _error(400)
+ return handle_edit_form(pid, page=query.get("p", [""])[0]) if pid is not None else _error(400)
elif path.startswith("/delete/"):
pid = extract_id("/delete/")
return handle_delete_confirm(pid) if pid is not None else _error(400)
@@ -129,11 +129,13 @@ def _dispatch_inner(data):
return handle_field_save(body)
elif path == "/style/reset":
set_setting("custom_template", "")
- return handle_style_form("Template reset to default.", gateway_host=gateway_host, scheme=scheme)
+ _set_flash("Template reset to default.")
+ return _redirect("/style")
elif path == "/style/vacuum":
from db import vacuum_db
vacuum_db()
- return handle_style_form("Database vacuumed.", gateway_host=gateway_host, scheme=scheme)
+ _set_flash("Database vacuumed.")
+ return _redirect("/style")
elif path == "/import":
return handle_import_submit(body)
elif path == "/reindex":
diff --git a/handlers/_helpers.py b/handlers/_helpers.py
index 3fecb71..2611ce7 100644
--- a/handlers/_helpers.py
+++ b/handlers/_helpers.py
@@ -65,11 +65,11 @@ def _get_bookmark_token():
return token
-def _respond(body_html, status=200, use_default=False):
+def _respond(body_html, status=200, use_default=False, head_html=""):
return {
"status": status,
"content_type": "text/html; charset=utf-8",
- "body": wrap_page(body_html, use_default=use_default),
+ "body": wrap_page(body_html, use_default=use_default, head_html=head_html),
"headers": {},
}
diff --git a/handlers/customize.py b/handlers/customize.py
index 6808046..6b3786d 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -1,9 +1,19 @@
from db import get_db, return_db, get_setting, set_setting, get_site_name
import templates as templates_mod
from templates import esc, DEFAULT_TEMPLATE
-from ._helpers import _respond, _json_response, _csrf_field, _get_bookmark_token
+from ._helpers import _respond, _redirect, _json_response, _csrf_field, _get_bookmark_token, _request_local
from .subscriptions import _count_shared_pages
+_flash = {}
+
+
+def _set_flash(msg):
+ _flash[_request_local.csrf_token] = msg
+
+
+def _get_flash():
+ return _flash.pop(_request_local.csrf_token, "")
+
def handle_style_form(msg="", gateway_host="", scheme="http"):
template = get_setting("custom_template") or DEFAULT_TEMPLATE
@@ -21,7 +31,6 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
reranker = get_setting("use_reranker", "0")
reranker_checked = " checked" if reranker == "1" else ""
disabled = "" if semantic == "1" else " disabled"
- dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
tcp_enabled = get_setting("tcp_enabled", "1")
tcp_checked = " checked" if tcp_enabled == "1" else ""
tcp_disabled = "" if tcp_enabled == "1" else " disabled"
@@ -32,31 +41,65 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
lora_enabled = get_setting("lora_enabled", "0")
lora_checked = " checked" if lora_enabled == "1" else ""
lora_disabled = "" if lora_enabled == "1" else " disabled"
- lora_dimmed = ' style="opacity:0.4"' if lora_enabled != "1" else ""
lora_port = get_setting("lora_port", "")
lora_frequency = get_setting("lora_frequency", "867200000")
lora_bandwidth = get_setting("lora_bandwidth", "125000")
lora_txpower = get_setting("lora_txpower", "7")
lora_sf = get_setting("lora_sf", "8")
lora_cr = get_setting("lora_cr", "5")
+ csrf = _csrf_field()
from handlers import forum_plugin as _fp
if _fp is not None:
- forum_section = (
+ forum_body = (
+ f""
)
+ forum_nav = ' · forum '
else:
- forum_section = ""
+ forum_body = ""
+ forum_nav = ""
+
+ msg = _get_flash() or msg
+ msg_html = ""
+ if msg:
+ msg_html = '{msg}
'.format(msg=esc(msg))
+
return _respond(
f"customize "
- f"name your search engine "
- f''
- f'{_csrf_field()}'
- f' '
+ f"{msg_html}"
+ f''
+ f'site name '
+ f' · sharing '
+ f'{forum_nav}'
+ f' · search '
+ f' · mesh '
+ f' · template '
+ f' · tools '
+ f' '
+ f" "
+ f""
+ f" "
+ f" '
f' '
f' share only pages tagged public '
- f'The private tag always excludes a page, even in public-only mode. '
- f''
+ f""
f''
f'Currently sharing {shared_count} page(s). '
- f'preview what subscribers would see '
- f'
'
+ f'preview '
+ f"
"
+ f' '
+ f""
+ f""
+ f" "
+ f"{forum_body}"
+ f" "
+ f""
+ f" "
+ f""
+ f" "
+ f""
+ f" "
+ f""
f'back ',
use_default=True,
)
def handle_style_submit(body, gateway_host="", scheme="http"):
- template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
- name = body.get("site_name", ["tinyweb"])[0].strip()
- sharing = "1" if body.get("sharing_enabled") else "0"
- sharing_mode = body.get("sharing_mode", ["exclude_private"])[0]
- if sharing_mode not in ("exclude_private", "require_public"):
- sharing_mode = "exclude_private"
- set_setting("sharing_mode", sharing_mode)
- semantic = "1" if body.get("semantic_search") else "0"
- reranker = "1" if body.get("use_reranker") else "0"
- compress = "1" if body.get("compress_embeddings") else "0"
- tcp_enabled = "1" if body.get("tcp_enabled") else "0"
- transport_host = body.get("transport_host", [""])[0].strip()
- transport_port = body.get("transport_port", [""])[0].strip()
- set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
- set_setting("site_name", name or "tinyweb")
- set_setting("sharing_enabled", sharing)
- set_setting("semantic_search", semantic)
- set_setting("use_reranker", reranker)
- set_setting("compress_embeddings", compress)
- set_setting("tcp_enabled", tcp_enabled)
- if transport_host:
- set_setting("transport_host", transport_host)
- if transport_port:
- set_setting("transport_port", transport_port)
- lora_enabled = "1" if body.get("lora_enabled") else "0"
- set_setting("lora_enabled", lora_enabled)
- set_setting("lora_port", body.get("lora_port", [""])[0].strip())
- set_setting("lora_frequency", body.get("lora_frequency", ["867200000"])[0].strip())
- set_setting("lora_bandwidth", body.get("lora_bandwidth", ["125000"])[0].strip())
- set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
- set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
- set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
- forum_enabled = "1" if body.get("forum_enabled") else "0"
- current_forum = get_setting("forum_enabled", "0")
- if forum_enabled != current_forum:
- from handlers import forum_plugin
- if forum_enabled == "1" and forum_plugin is None:
- return handle_style_form(
- "Forum plugin not installed. Run: pip install tinyweb-forum",
- gateway_host=gateway_host, scheme=scheme,
- )
- if forum_enabled == "1":
- forum_plugin.enable()
- try:
- forum_plugin.fdb.set_setting("forum_enabled", "1")
- except Exception:
- pass
- else:
- forum_plugin.disable()
- try:
- forum_plugin.fdb.set_setting("forum_enabled", "0")
- except Exception:
- pass
- set_setting("forum_enabled", forum_enabled)
- templates_mod.FORUM_ENABLED = (forum_enabled == "1")
- return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.",
- gateway_host=gateway_host, scheme=scheme)
+ action = body.get("_action", [""])[0]
+
+ if action == "name":
+ name = body.get("site_name", ["tinyweb"])[0].strip()
+ set_setting("site_name", name or "tinyweb")
+ _set_flash("Saved.")
+ return _redirect("/style")
+
+ if action == "sharing":
+ sharing = "1" if body.get("sharing_enabled") else "0"
+ sharing_mode = body.get("sharing_mode", ["exclude_private"])[0]
+ if sharing_mode not in ("exclude_private", "require_public"):
+ sharing_mode = "exclude_private"
+ set_setting("sharing_mode", sharing_mode)
+ set_setting("sharing_enabled", sharing)
+ _set_flash("Saved.")
+ return _redirect("/style")
+
+ if action == "forum":
+ forum_enabled = "1" if body.get("forum_enabled") else "0"
+ current_forum = get_setting("forum_enabled", "0")
+ if forum_enabled != current_forum:
+ from handlers import forum_plugin
+ if forum_enabled == "1" and forum_plugin is None:
+ _set_flash("Forum plugin not installed. Run: pip install tinyweb-forum")
+ return _redirect("/style")
+ if forum_enabled == "1":
+ forum_plugin.enable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "1")
+ except Exception:
+ pass
+ else:
+ forum_plugin.disable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "0")
+ except Exception:
+ pass
+ set_setting("forum_enabled", forum_enabled)
+ templates_mod.FORUM_ENABLED = (forum_enabled == "1")
+ _set_flash("Saved.")
+ return _redirect("/style")
+
+ if action == "search":
+ semantic = "1" if body.get("semantic_search") else "0"
+ reranker = "1" if body.get("use_reranker") else "0"
+ compress = "1" if body.get("compress_embeddings") else "0"
+ set_setting("semantic_search", semantic)
+ set_setting("use_reranker", reranker)
+ set_setting("compress_embeddings", compress)
+ _set_flash("Saved.")
+ return _redirect("/style")
+
+ if action == "mesh":
+ tcp_enabled = "1" if body.get("tcp_enabled") else "0"
+ transport_host = body.get("transport_host", [""])[0].strip()
+ transport_port = body.get("transport_port", [""])[0].strip()
+ set_setting("tcp_enabled", tcp_enabled)
+ if transport_host:
+ set_setting("transport_host", transport_host)
+ if transport_port:
+ set_setting("transport_port", transport_port)
+ lora_enabled = "1" if body.get("lora_enabled") else "0"
+ set_setting("lora_enabled", lora_enabled)
+ set_setting("lora_port", body.get("lora_port", [""])[0].strip())
+ set_setting("lora_frequency", body.get("lora_frequency", ["867200000"])[0].strip())
+ set_setting("lora_bandwidth", body.get("lora_bandwidth", ["125000"])[0].strip())
+ set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
+ set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
+ set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
+ _set_flash("Saved.")
+ return _redirect("/style")
def handle_style_template_submit(body, gateway_host="", scheme="http"):
template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
- return handle_style_form("Template saved.", gateway_host=gateway_host, scheme=scheme)
+ _set_flash("Template saved.")
+ return _redirect("/style")
def handle_field_save(body):
diff --git a/handlers/pages.py b/handlers/pages.py
index cd3efba..0799251 100644
--- a/handlers/pages.py
+++ b/handlers/pages.py
@@ -172,7 +172,7 @@ def handle_pages(query=None):
f' '
f'{esc(r["title"])} {note_html}{tags_html} '
f'({esc(r["url"])} ) '
- f'edit '
+ f'edit '
f'remove '
)
finally:
@@ -282,7 +282,7 @@ def handle_bulk_action(body):
return _redirect("/pages")
-def handle_edit_form(page_id, msg=""):
+def handle_edit_form(page_id, msg="", page=""):
db = get_db()
try:
row = db.execute("SELECT id, url, title, body, note, summary FROM pages WHERE id = ?", (page_id,)).fetchone()
@@ -292,12 +292,15 @@ def handle_edit_form(page_id, msg=""):
finally:
return_db(db)
+ back = "/pages" + ("?p=" + page if page else "")
+
return _respond(
f"edit page "
f"{esc(row['title'])} "
f"{esc(row['url'])}
"
f''
f'{_csrf_field()}'
+ f' '
f'Title: '
f' '
f'Summary (shown in search results): '
@@ -310,7 +313,7 @@ def handle_edit_form(page_id, msg=""):
f'save '
f" "
f"{msg}
"
- f'back '
+ f'back '
)
@@ -335,7 +338,11 @@ def handle_edit_submit(page_id, body):
finally:
return_db(db)
- return _redirect("/pages")
+ page = body.get("_page", [""])[0].strip()
+ target = "/pages"
+ if page:
+ target += "?p=" + page
+ return _redirect(target)
def handle_delete_confirm(page_id):
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
index e0ecf68..97f20b2 100644
--- a/handlers/subscriptions.py
+++ b/handlers/subscriptions.py
@@ -162,7 +162,7 @@ def handle_subscriptions(msg=""):
sync_btn = 'syncing... '
else:
sync_btn = (
- f''
+ f' '
f'{_csrf_field()}sync now '
)
@@ -173,18 +173,22 @@ def handle_subscriptions(msg=""):
f'last sync: {esc(last)}
'
f'{status_html}'
f''
- f'
browse '
+ f'
browse '
f'{sync_btn}'
- f'
'
+ f' '
f'{_csrf_field()}auto-sync: {auto_label} '
- f'
'
+ f' '
f'{_csrf_field()}remove '
f'
'
f''
)
+ any_syncing = any(
+ s["id"] in _sync_threads and _sync_threads[s["id"]].is_alive()
+ for s in subs
+ )
+ head_html = ' ' if any_syncing else ""
listing = ""
if subs:
- any_syncing = any(sid in _sync_threads and _sync_threads[sid].is_alive() for sid in [s["id"] for s in subs])
syncall_btn = 'syncing... ' if any_syncing else 'sync all '
listing = (
f'{cards}'
@@ -201,7 +205,8 @@ def handle_subscriptions(msg=""):
f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
- f'back '
+ f'back ',
+ head_html=head_html,
)
diff --git a/templates.py b/templates.py
index 89dadd7..27a1a6b 100644
--- a/templates.py
+++ b/templates.py
@@ -7,33 +7,6 @@ def esc(s):
return html.escape(str(s))
-FORUM_CSS = """
-"""
-
def _nav_html():
name = esc(get_setting("site_name", "tinyweb"))
forum_link = ' | forum ' if FORUM_ENABLED else ""
@@ -56,7 +29,7 @@ def _default_template():
)
-def wrap_page(body_html, use_default=False):
+def wrap_page(body_html, use_default=False, head_html=""):
if use_default:
template = _default_template()
else:
@@ -67,8 +40,6 @@ def wrap_page(body_html, use_default=False):
template = template.replace("{{forum_link}}", forum_link)
template = template.replace("{{site_name}}", esc(get_setting("site_name", "tinyweb")))
template = template.replace("{{nav}}", _nav_html())
- # Inject forum layout CSS into for any template
- head_end = ""
- if head_end in template and FORUM_CSS not in template:
- template = template.replace(head_end, FORUM_CSS + head_end)
+ if head_html:
+ template = template.replace("", head_html + "")
return template.replace("{{content}}", body_html)
diff --git a/themes/tinyweb-site.html b/themes/default.html
similarity index 100%
rename from themes/tinyweb-site.html
rename to themes/default.html
diff --git a/themes/kodama.html b/themes/kodama.html
index 503ac9a..34fc125 100644
--- a/themes/kodama.html
+++ b/themes/kodama.html
@@ -1,747 +1,1424 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- tinyweb
-
-
-
-
- {{content}}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/themes/kodama2.html b/themes/kodama2.html
deleted file mode 100644
index 34fc125..0000000
--- a/themes/kodama2.html
+++ /dev/null
@@ -1,1424 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
From 7a81b519bb1fbe037435d787799f127a1ce7d975 Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 16 Jun 2026 05:11:37 +0000
Subject: [PATCH 174/194] remove demo pages, rename themes, add Content-Length
header
---
gateway.py | 9 +-
handlers/__init__.py | 10 +-
handlers/_helpers.py | 4 +-
handlers/customize.py | 339 +--
handlers/pages.py | 15 +-
handlers/subscriptions.py | 17 +-
templates.py | 35 +-
themes/{tinyweb-site.html => default.html} | 0
themes/kodama.html | 2169 +++++++++++++-------
themes/kodama2.html | 1424 -------------
10 files changed, 1652 insertions(+), 2370 deletions(-)
rename themes/{tinyweb-site.html => default.html} (100%)
delete mode 100644 themes/kodama2.html
diff --git a/gateway.py b/gateway.py
index 02a2d95..fa4b076 100644
--- a/gateway.py
+++ b/gateway.py
@@ -165,12 +165,15 @@ class GatewayHandler(BaseHTTPRequestHandler):
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; "
"img-src 'self' data:")
+ resp_body = resp.get("body", "")
+ encoded = resp_body.encode() if isinstance(resp_body, str) else resp_body
+ if encoded:
+ self.send_header("Content-Length", str(len(encoded)))
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)
+ if encoded:
+ self.wfile.write(encoded)
except ConnectionError as e:
GatewayState.link = None
diff --git a/handlers/__init__.py b/handlers/__init__.py
index e7eda3e..228508e 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -32,7 +32,7 @@ from .subscriptions import (
handle_subscription_delete, handle_subscription_syncall,
_sync_threads,
)
-from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_field_save, handle_about
+from .customize import handle_style_form, handle_style_submit, handle_style_template_submit, handle_field_save, handle_about, _set_flash
from .tags import handle_tags, handle_tag_browse
from .data import (
handle_export, handle_import_form, handle_import_submit,
@@ -66,7 +66,7 @@ def _dispatch_inner(data):
return handle_pages(query)
elif path.startswith("/edit/"):
pid = extract_id("/edit/")
- return handle_edit_form(pid) if pid is not None else _error(400)
+ return handle_edit_form(pid, page=query.get("p", [""])[0]) if pid is not None else _error(400)
elif path.startswith("/delete/"):
pid = extract_id("/delete/")
return handle_delete_confirm(pid) if pid is not None else _error(400)
@@ -129,11 +129,13 @@ def _dispatch_inner(data):
return handle_field_save(body)
elif path == "/style/reset":
set_setting("custom_template", "")
- return handle_style_form("Template reset to default.", gateway_host=gateway_host, scheme=scheme)
+ _set_flash("Template reset to default.")
+ return _redirect("/style")
elif path == "/style/vacuum":
from db import vacuum_db
vacuum_db()
- return handle_style_form("Database vacuumed.", gateway_host=gateway_host, scheme=scheme)
+ _set_flash("Database vacuumed.")
+ return _redirect("/style")
elif path == "/import":
return handle_import_submit(body)
elif path == "/reindex":
diff --git a/handlers/_helpers.py b/handlers/_helpers.py
index 3fecb71..2611ce7 100644
--- a/handlers/_helpers.py
+++ b/handlers/_helpers.py
@@ -65,11 +65,11 @@ def _get_bookmark_token():
return token
-def _respond(body_html, status=200, use_default=False):
+def _respond(body_html, status=200, use_default=False, head_html=""):
return {
"status": status,
"content_type": "text/html; charset=utf-8",
- "body": wrap_page(body_html, use_default=use_default),
+ "body": wrap_page(body_html, use_default=use_default, head_html=head_html),
"headers": {},
}
diff --git a/handlers/customize.py b/handlers/customize.py
index 6808046..6b3786d 100644
--- a/handlers/customize.py
+++ b/handlers/customize.py
@@ -1,9 +1,19 @@
from db import get_db, return_db, get_setting, set_setting, get_site_name
import templates as templates_mod
from templates import esc, DEFAULT_TEMPLATE
-from ._helpers import _respond, _json_response, _csrf_field, _get_bookmark_token
+from ._helpers import _respond, _redirect, _json_response, _csrf_field, _get_bookmark_token, _request_local
from .subscriptions import _count_shared_pages
+_flash = {}
+
+
+def _set_flash(msg):
+ _flash[_request_local.csrf_token] = msg
+
+
+def _get_flash():
+ return _flash.pop(_request_local.csrf_token, "")
+
def handle_style_form(msg="", gateway_host="", scheme="http"):
template = get_setting("custom_template") or DEFAULT_TEMPLATE
@@ -21,7 +31,6 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
reranker = get_setting("use_reranker", "0")
reranker_checked = " checked" if reranker == "1" else ""
disabled = "" if semantic == "1" else " disabled"
- dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
tcp_enabled = get_setting("tcp_enabled", "1")
tcp_checked = " checked" if tcp_enabled == "1" else ""
tcp_disabled = "" if tcp_enabled == "1" else " disabled"
@@ -32,31 +41,65 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
lora_enabled = get_setting("lora_enabled", "0")
lora_checked = " checked" if lora_enabled == "1" else ""
lora_disabled = "" if lora_enabled == "1" else " disabled"
- lora_dimmed = ' style="opacity:0.4"' if lora_enabled != "1" else ""
lora_port = get_setting("lora_port", "")
lora_frequency = get_setting("lora_frequency", "867200000")
lora_bandwidth = get_setting("lora_bandwidth", "125000")
lora_txpower = get_setting("lora_txpower", "7")
lora_sf = get_setting("lora_sf", "8")
lora_cr = get_setting("lora_cr", "5")
+ csrf = _csrf_field()
from handlers import forum_plugin as _fp
if _fp is not None:
- forum_section = (
+ forum_body = (
+ f""
)
+ forum_nav = ' · forum '
else:
- forum_section = ""
+ forum_body = ""
+ forum_nav = ""
+
+ msg = _get_flash() or msg
+ msg_html = ""
+ if msg:
+ msg_html = '{msg}
'.format(msg=esc(msg))
+
return _respond(
f"customize "
- f"name your search engine "
- f''
- f'{_csrf_field()}'
- f' '
+ f"{msg_html}"
+ f''
+ f'site name '
+ f' · sharing '
+ f'{forum_nav}'
+ f' · search '
+ f' · mesh '
+ f' · template '
+ f' · tools '
+ f' '
+ f" "
+ f""
+ f" "
+ f"
"
+ f' '
+ f""
+ f""
+ f" "
+ f"{forum_body}"
+ f" "
+ f""
+ f" "
+ f""
+ f" "
+ f""
+ f" "
+ f""
f'back ',
use_default=True,
)
def handle_style_submit(body, gateway_host="", scheme="http"):
- template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
- name = body.get("site_name", ["tinyweb"])[0].strip()
- sharing = "1" if body.get("sharing_enabled") else "0"
- sharing_mode = body.get("sharing_mode", ["exclude_private"])[0]
- if sharing_mode not in ("exclude_private", "require_public"):
- sharing_mode = "exclude_private"
- set_setting("sharing_mode", sharing_mode)
- semantic = "1" if body.get("semantic_search") else "0"
- reranker = "1" if body.get("use_reranker") else "0"
- compress = "1" if body.get("compress_embeddings") else "0"
- tcp_enabled = "1" if body.get("tcp_enabled") else "0"
- transport_host = body.get("transport_host", [""])[0].strip()
- transport_port = body.get("transport_port", [""])[0].strip()
- set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
- set_setting("site_name", name or "tinyweb")
- set_setting("sharing_enabled", sharing)
- set_setting("semantic_search", semantic)
- set_setting("use_reranker", reranker)
- set_setting("compress_embeddings", compress)
- set_setting("tcp_enabled", tcp_enabled)
- if transport_host:
- set_setting("transport_host", transport_host)
- if transport_port:
- set_setting("transport_port", transport_port)
- lora_enabled = "1" if body.get("lora_enabled") else "0"
- set_setting("lora_enabled", lora_enabled)
- set_setting("lora_port", body.get("lora_port", [""])[0].strip())
- set_setting("lora_frequency", body.get("lora_frequency", ["867200000"])[0].strip())
- set_setting("lora_bandwidth", body.get("lora_bandwidth", ["125000"])[0].strip())
- set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
- set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
- set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
- forum_enabled = "1" if body.get("forum_enabled") else "0"
- current_forum = get_setting("forum_enabled", "0")
- if forum_enabled != current_forum:
- from handlers import forum_plugin
- if forum_enabled == "1" and forum_plugin is None:
- return handle_style_form(
- "Forum plugin not installed. Run: pip install tinyweb-forum",
- gateway_host=gateway_host, scheme=scheme,
- )
- if forum_enabled == "1":
- forum_plugin.enable()
- try:
- forum_plugin.fdb.set_setting("forum_enabled", "1")
- except Exception:
- pass
- else:
- forum_plugin.disable()
- try:
- forum_plugin.fdb.set_setting("forum_enabled", "0")
- except Exception:
- pass
- set_setting("forum_enabled", forum_enabled)
- templates_mod.FORUM_ENABLED = (forum_enabled == "1")
- return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.",
- gateway_host=gateway_host, scheme=scheme)
+ action = body.get("_action", [""])[0]
+
+ if action == "name":
+ name = body.get("site_name", ["tinyweb"])[0].strip()
+ set_setting("site_name", name or "tinyweb")
+ _set_flash("Saved.")
+ return _redirect("/style")
+
+ if action == "sharing":
+ sharing = "1" if body.get("sharing_enabled") else "0"
+ sharing_mode = body.get("sharing_mode", ["exclude_private"])[0]
+ if sharing_mode not in ("exclude_private", "require_public"):
+ sharing_mode = "exclude_private"
+ set_setting("sharing_mode", sharing_mode)
+ set_setting("sharing_enabled", sharing)
+ _set_flash("Saved.")
+ return _redirect("/style")
+
+ if action == "forum":
+ forum_enabled = "1" if body.get("forum_enabled") else "0"
+ current_forum = get_setting("forum_enabled", "0")
+ if forum_enabled != current_forum:
+ from handlers import forum_plugin
+ if forum_enabled == "1" and forum_plugin is None:
+ _set_flash("Forum plugin not installed. Run: pip install tinyweb-forum")
+ return _redirect("/style")
+ if forum_enabled == "1":
+ forum_plugin.enable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "1")
+ except Exception:
+ pass
+ else:
+ forum_plugin.disable()
+ try:
+ forum_plugin.fdb.set_setting("forum_enabled", "0")
+ except Exception:
+ pass
+ set_setting("forum_enabled", forum_enabled)
+ templates_mod.FORUM_ENABLED = (forum_enabled == "1")
+ _set_flash("Saved.")
+ return _redirect("/style")
+
+ if action == "search":
+ semantic = "1" if body.get("semantic_search") else "0"
+ reranker = "1" if body.get("use_reranker") else "0"
+ compress = "1" if body.get("compress_embeddings") else "0"
+ set_setting("semantic_search", semantic)
+ set_setting("use_reranker", reranker)
+ set_setting("compress_embeddings", compress)
+ _set_flash("Saved.")
+ return _redirect("/style")
+
+ if action == "mesh":
+ tcp_enabled = "1" if body.get("tcp_enabled") else "0"
+ transport_host = body.get("transport_host", [""])[0].strip()
+ transport_port = body.get("transport_port", [""])[0].strip()
+ set_setting("tcp_enabled", tcp_enabled)
+ if transport_host:
+ set_setting("transport_host", transport_host)
+ if transport_port:
+ set_setting("transport_port", transport_port)
+ lora_enabled = "1" if body.get("lora_enabled") else "0"
+ set_setting("lora_enabled", lora_enabled)
+ set_setting("lora_port", body.get("lora_port", [""])[0].strip())
+ set_setting("lora_frequency", body.get("lora_frequency", ["867200000"])[0].strip())
+ set_setting("lora_bandwidth", body.get("lora_bandwidth", ["125000"])[0].strip())
+ set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
+ set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
+ set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
+ _set_flash("Saved.")
+ return _redirect("/style")
def handle_style_template_submit(body, gateway_host="", scheme="http"):
template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
- return handle_style_form("Template saved.", gateway_host=gateway_host, scheme=scheme)
+ _set_flash("Template saved.")
+ return _redirect("/style")
def handle_field_save(body):
diff --git a/handlers/pages.py b/handlers/pages.py
index cd3efba..0799251 100644
--- a/handlers/pages.py
+++ b/handlers/pages.py
@@ -172,7 +172,7 @@ def handle_pages(query=None):
f' '
f'{esc(r["title"])} {note_html}{tags_html} '
f'({esc(r["url"])} ) '
- f'edit '
+ f'edit '
f'remove '
)
finally:
@@ -282,7 +282,7 @@ def handle_bulk_action(body):
return _redirect("/pages")
-def handle_edit_form(page_id, msg=""):
+def handle_edit_form(page_id, msg="", page=""):
db = get_db()
try:
row = db.execute("SELECT id, url, title, body, note, summary FROM pages WHERE id = ?", (page_id,)).fetchone()
@@ -292,12 +292,15 @@ def handle_edit_form(page_id, msg=""):
finally:
return_db(db)
+ back = "/pages" + ("?p=" + page if page else "")
+
return _respond(
f"edit page "
f"{esc(row['title'])} "
f"{esc(row['url'])}
"
f''
f'{_csrf_field()}'
+ f' '
f'Title: '
f' '
f'Summary (shown in search results): '
@@ -310,7 +313,7 @@ def handle_edit_form(page_id, msg=""):
f'save '
f" "
f"{msg}
"
- f'back '
+ f'back '
)
@@ -335,7 +338,11 @@ def handle_edit_submit(page_id, body):
finally:
return_db(db)
- return _redirect("/pages")
+ page = body.get("_page", [""])[0].strip()
+ target = "/pages"
+ if page:
+ target += "?p=" + page
+ return _redirect(target)
def handle_delete_confirm(page_id):
diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py
index e0ecf68..97f20b2 100644
--- a/handlers/subscriptions.py
+++ b/handlers/subscriptions.py
@@ -162,7 +162,7 @@ def handle_subscriptions(msg=""):
sync_btn = 'syncing... '
else:
sync_btn = (
- f''
+ f' '
f'{_csrf_field()}sync now '
)
@@ -173,18 +173,22 @@ def handle_subscriptions(msg=""):
f'last sync: {esc(last)}
'
f'{status_html}'
f''
- f'
browse '
+ f'
browse '
f'{sync_btn}'
- f'
'
+ f' '
f'{_csrf_field()}auto-sync: {auto_label} '
- f'
'
+ f' '
f'{_csrf_field()}remove '
f'
'
f''
)
+ any_syncing = any(
+ s["id"] in _sync_threads and _sync_threads[s["id"]].is_alive()
+ for s in subs
+ )
+ head_html = ' ' if any_syncing else ""
listing = ""
if subs:
- any_syncing = any(sid in _sync_threads and _sync_threads[sid].is_alive() for sid in [s["id"] for s in subs])
syncall_btn = 'syncing... ' if any_syncing else 'sync all '
listing = (
f'{cards}'
@@ -201,7 +205,8 @@ def handle_subscriptions(msg=""):
f'or subscribe to an instance
'
f'{msg}
'
f' {listing}'
- f'back '
+ f'back ',
+ head_html=head_html,
)
diff --git a/templates.py b/templates.py
index 89dadd7..27a1a6b 100644
--- a/templates.py
+++ b/templates.py
@@ -7,33 +7,6 @@ def esc(s):
return html.escape(str(s))
-FORUM_CSS = """
-"""
-
def _nav_html():
name = esc(get_setting("site_name", "tinyweb"))
forum_link = ' | forum ' if FORUM_ENABLED else ""
@@ -56,7 +29,7 @@ def _default_template():
)
-def wrap_page(body_html, use_default=False):
+def wrap_page(body_html, use_default=False, head_html=""):
if use_default:
template = _default_template()
else:
@@ -67,8 +40,6 @@ def wrap_page(body_html, use_default=False):
template = template.replace("{{forum_link}}", forum_link)
template = template.replace("{{site_name}}", esc(get_setting("site_name", "tinyweb")))
template = template.replace("{{nav}}", _nav_html())
- # Inject forum layout CSS into for any template
- head_end = ""
- if head_end in template and FORUM_CSS not in template:
- template = template.replace(head_end, FORUM_CSS + head_end)
+ if head_html:
+ template = template.replace("", head_html + "")
return template.replace("{{content}}", body_html)
diff --git a/themes/tinyweb-site.html b/themes/default.html
similarity index 100%
rename from themes/tinyweb-site.html
rename to themes/default.html
diff --git a/themes/kodama.html b/themes/kodama.html
index 503ac9a..34fc125 100644
--- a/themes/kodama.html
+++ b/themes/kodama.html
@@ -1,747 +1,1424 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- tinyweb
-
-
-
-
- {{content}}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/themes/kodama2.html b/themes/kodama2.html
deleted file mode 100644
index 34fc125..0000000
--- a/themes/kodama2.html
+++ /dev/null
@@ -1,1424 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
From afbd3c89c1025362da40d0044130bfc0112c0c3d Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 16 Jun 2026 05:18:39 +0000
Subject: [PATCH 175/194] junimo theme: use {{site_name}} and {{forum_link}}
placeholders
---
themes/junimo.html | 48 +++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 45 insertions(+), 3 deletions(-)
diff --git a/themes/junimo.html b/themes/junimo.html
index f85e315..ee3fdcc 100644
--- a/themes/junimo.html
+++ b/themes/junimo.html
@@ -373,12 +373,25 @@
/* inputs */
input[type="text"],
input[type="url"],
+ input[type="search"],
+ input:not([type]),
input[name="q"],
+ input[name="title"],
input[name="url"],
- input[name="note"],
input[name="tags"],
+ input[name="topics"],
+ input[name="instance"],
+ input[name="name"],
+ input[name="keywords"],
+ input[name="retention_days"],
+ input[name="note"],
input[name="site_name"],
- input[name="dest_hash"] {
+ input[name="dest_hash"],
+ input[name="manual_title"],
+ input[name="summary"],
+ input[name="transport_host"],
+ input[name="transport_port"],
+ textarea, select {
background: rgba(20, 15, 50, 0.6);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
@@ -547,6 +560,34 @@
hr { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 1rem 0; }
+ .forum-form input, .forum-form button, .forum-toolbar input { border-radius: 4px; }
+ .forum-actions a, a.forum-action, a.forum-action-inline {
+ border: 1px solid rgba(255,255,255,0.12); padding: 6px 14px; text-transform: uppercase; font-size: 13px; background: rgba(30,20,60,0.7); color: #a098b0;
+ }
+ .forum-actions a:hover, a.forum-action:hover, a.forum-action-inline:hover {
+ background: rgba(50,35,90,0.7); color: #d0c0e0; border-color: rgba(240,216,120,0.3);
+ }
+ a.forum-action-inline { text-transform: none; font-size: 13px; padding: 2px 6px; border: none; background: none; }
+ .forum-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0.5rem 0; }
+ .forum-toolbar form { flex: 1; min-width: 160px; margin: 0; }
+ .forum-toolbar input[name=q] { width: 100%; box-sizing: border-box; padding: 6px 10px; }
+ .forum-toolbar-actions { display: flex; flex-wrap: wrap; gap: 4px; }
+ .section { margin: 1.5rem 0; }
+ .section-title { font-weight: 600; margin-bottom: 0.3rem; color: #d0c0a0; }
+ .section-desc { font-size: 0.85rem; color: #5a5070; margin-bottom: 0.5rem; }
+ .section ul { margin: 0.3rem 0; }
+ .forum-form input, .forum-form textarea, .forum-form button { margin-bottom: 8px; }
+ .forum-form small { display: block; margin-bottom: 6px; }
+ .forum-form label { display: block; margin-bottom: 6px; }
+ .forum-form + .forum-form { margin-top: 1rem; }
+ .forum-form + .section-title { margin-top: 1rem; }
+ .section-desc + .forum-form { margin-top: 0.8rem; }
+ ul + .forum-form { margin-top: 1rem; }
+ .checkbox-label { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
+ .forum-status { font-size: 0.82rem; color: #5a5070; margin: 0 0 0.8rem 0; }
+ .forum-status span { margin-right: 1.2rem; }
+ .forum-nav { margin: 1rem 0; }
+
small {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.7rem;
@@ -615,11 +656,12 @@
- tinyweb
+ {{site_name}}
From fc4e0c0b1d2dedff0dd8a51c9431ba11abe4474a Mon Sep 17 00:00:00 2001
From: blankie
Date: Tue, 16 Jun 2026 05:18:39 +0000
Subject: [PATCH 176/194] junimo theme: use {{site_name}} and {{forum_link}}
placeholders
---
themes/junimo.html | 48 +++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 45 insertions(+), 3 deletions(-)
diff --git a/themes/junimo.html b/themes/junimo.html
index f85e315..ee3fdcc 100644
--- a/themes/junimo.html
+++ b/themes/junimo.html
@@ -373,12 +373,25 @@
/* inputs */
input[type="text"],
input[type="url"],
+ input[type="search"],
+ input:not([type]),
input[name="q"],
+ input[name="title"],
input[name="url"],
- input[name="note"],
input[name="tags"],
+ input[name="topics"],
+ input[name="instance"],
+ input[name="name"],
+ input[name="keywords"],
+ input[name="retention_days"],
+ input[name="note"],
input[name="site_name"],
- input[name="dest_hash"] {
+ input[name="dest_hash"],
+ input[name="manual_title"],
+ input[name="summary"],
+ input[name="transport_host"],
+ input[name="transport_port"],
+ textarea, select {
background: rgba(20, 15, 50, 0.6);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
@@ -547,6 +560,34 @@
hr { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 1rem 0; }
+ .forum-form input, .forum-form button, .forum-toolbar input { border-radius: 4px; }
+ .forum-actions a, a.forum-action, a.forum-action-inline {
+ border: 1px solid rgba(255,255,255,0.12); padding: 6px 14px; text-transform: uppercase; font-size: 13px; background: rgba(30,20,60,0.7); color: #a098b0;
+ }
+ .forum-actions a:hover, a.forum-action:hover, a.forum-action-inline:hover {
+ background: rgba(50,35,90,0.7); color: #d0c0e0; border-color: rgba(240,216,120,0.3);
+ }
+ a.forum-action-inline { text-transform: none; font-size: 13px; padding: 2px 6px; border: none; background: none; }
+ .forum-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0.5rem 0; }
+ .forum-toolbar form { flex: 1; min-width: 160px; margin: 0; }
+ .forum-toolbar input[name=q] { width: 100%; box-sizing: border-box; padding: 6px 10px; }
+ .forum-toolbar-actions { display: flex; flex-wrap: wrap; gap: 4px; }
+ .section { margin: 1.5rem 0; }
+ .section-title { font-weight: 600; margin-bottom: 0.3rem; color: #d0c0a0; }
+ .section-desc { font-size: 0.85rem; color: #5a5070; margin-bottom: 0.5rem; }
+ .section ul { margin: 0.3rem 0; }
+ .forum-form input, .forum-form textarea, .forum-form button { margin-bottom: 8px; }
+ .forum-form small { display: block; margin-bottom: 6px; }
+ .forum-form label { display: block; margin-bottom: 6px; }
+ .forum-form + .forum-form { margin-top: 1rem; }
+ .forum-form + .section-title { margin-top: 1rem; }
+ .section-desc + .forum-form { margin-top: 0.8rem; }
+ ul + .forum-form { margin-top: 1rem; }
+ .checkbox-label { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
+ .forum-status { font-size: 0.82rem; color: #5a5070; margin: 0 0 0.8rem 0; }
+ .forum-status span { margin-right: 1.2rem; }
+ .forum-nav { margin: 1rem 0; }
+
small {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 0.7rem;
@@ -615,11 +656,12 @@
- tinyweb
+ {{site_name}}
From 0669aaf3a08be69cc0d7e5cbe07015a056cf8f3e Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 17 Jun 2026 05:03:23 +0000
Subject: [PATCH 177/194] src layout: move core code into src/tinyweb/ package
- Moved app.py, db.py, gateway.py, templates.py, embeddings.py,
rns_client.py, and handlers/ into src/tinyweb/
- Created root app.py shim (adds src/ to sys.path, imports main)
- Created pyproject.toml with setuptools config (where = ["src"])
- Added src/tinyweb/__init__.py
- Updated all internal imports to use tinyweb. prefix (73 occurrences)
- Removed sys.path.insert hack from conftest.py
- Updated Dockerfile: pip install -e /app before running
- Updated gateway.py usage message: python -m tinyweb.gateway
- Updated README.md gateway usage instructions
---
Dockerfile | 2 +
README.md | 2 +-
app.py | 315 +-----------------
conftest.py | 8 +-
pyproject.toml | 12 +
src/tinyweb/__init__.py | 1 +
src/tinyweb/app.py | 312 +++++++++++++++++
db.py => src/tinyweb/db.py | 2 +-
embeddings.py => src/tinyweb/embeddings.py | 10 +-
gateway.py => src/tinyweb/gateway.py | 2 +-
.../tinyweb/handlers}/__init__.py | 10 +-
.../tinyweb/handlers}/_helpers.py | 4 +-
.../tinyweb/handlers}/customize.py | 12 +-
{handlers => src/tinyweb/handlers}/data.py | 6 +-
{handlers => src/tinyweb/handlers}/pages.py | 6 +-
{handlers => src/tinyweb/handlers}/search.py | 6 +-
.../tinyweb/handlers}/subscriptions.py | 8 +-
{handlers => src/tinyweb/handlers}/tags.py | 4 +-
rns_client.py => src/tinyweb/rns_client.py | 0
templates.py => src/tinyweb/templates.py | 2 +-
tests/test_csrf.py | 4 +-
tests/test_db_index_url.py | 4 +-
tests/test_db_schema.py | 2 +-
tests/test_fts_sanitizer.py | 2 +-
tests/test_gateway_limits.py | 6 +-
tests/test_handlers_pages.py | 4 +-
tests/test_handlers_search.py | 2 +-
tests/test_handlers_subs.py | 6 +-
tests/test_handlers_tags.py | 4 +-
tests/test_link_extraction.py | 2 +-
tests/test_pagination.py | 2 +-
tests/test_regressions.py | 16 +-
tests/test_sharing_logic.py | 2 +-
tests/test_ssrf.py | 2 +-
tests/test_url_cleanup.py | 2 +-
35 files changed, 400 insertions(+), 384 deletions(-)
create mode 100644 pyproject.toml
create mode 100644 src/tinyweb/__init__.py
create mode 100644 src/tinyweb/app.py
rename db.py => src/tinyweb/db.py (99%)
rename embeddings.py => src/tinyweb/embeddings.py (98%)
rename gateway.py => src/tinyweb/gateway.py (99%)
rename {handlers => src/tinyweb/handlers}/__init__.py (97%)
rename {handlers => src/tinyweb/handlers}/_helpers.py (97%)
rename {handlers => src/tinyweb/handlers}/customize.py (98%)
rename {handlers => src/tinyweb/handlers}/data.py (95%)
rename {handlers => src/tinyweb/handlers}/pages.py (98%)
rename {handlers => src/tinyweb/handlers}/search.py (97%)
rename {handlers => src/tinyweb/handlers}/subscriptions.py (98%)
rename {handlers => src/tinyweb/handlers}/tags.py (96%)
rename rns_client.py => src/tinyweb/rns_client.py (100%)
rename templates.py => src/tinyweb/templates.py (97%)
diff --git a/Dockerfile b/Dockerfile
index 3f73263..de57fda 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,6 +13,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
+RUN pip install -e /app
+
RUN mkdir -p /data
ENV PYTHONUNBUFFERED=1
diff --git a/README.md b/README.md
index 991c981..c092eda 100644
--- a/README.md
+++ b/README.md
@@ -163,7 +163,7 @@ The `/export` page produces a JSON dump of your pages. It's a migration aid —
To browse a remote TinyWeb instance without running your own index:
```bash
-python gateway.py
+python -m tinyweb.gateway
```
This connects over Reticulum and serves the remote instance at `http://localhost:8080`.
diff --git a/app.py b/app.py
index 035eca0..a5bf38b 100644
--- a/app.py
+++ b/app.py
@@ -1,312 +1,5 @@
-import os
import sys
-import time
-import threading
-import argparse
-import RNS
-from http.server import HTTPServer, ThreadingHTTPServer
-
-from db import init_db, get_setting, set_setting
-from handlers import dispatch_request
-import handlers as handlers_mod
-import templates as templates_mod
-import gateway
-from gateway import GatewayState, GatewayHandler
-
-IDENTITY_FILE = "tinyweb_identity"
-DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
-DEFAULT_TRANSPORT_PORT = 4242
-DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb")
-
-
-def get_transport_config():
- host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
- port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))
- return host, int(port)
-
-
-def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"):
- """Find an available port starting from start."""
- import socket
- for port in range(start, start + max_attempts):
- try:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- s.bind((host, port))
- return port
- except OSError:
- continue
- return start
-
-
-def get_version():
- """Get version from git tag or VERSION file."""
- try:
- import subprocess
- tag = subprocess.check_output(
- ["git", "describe", "--tags", "--abbrev=0"],
- stderr=subprocess.DEVNULL,
- text=True
- ).strip()
- if tag.startswith("v"):
- return tag[1:]
- return tag
- except Exception:
- version_file = os.path.join(os.path.dirname(__file__), "VERSION")
- if os.path.exists(version_file):
- with open(version_file) as f:
- return f.read().strip()
- return "0.0.0"
-
-
-def load_or_create_identity():
- os.makedirs(DATA_DIR, exist_ok=True)
- identity_path = os.path.join(DATA_DIR, IDENTITY_FILE)
- if os.path.isfile(identity_path):
- current = os.stat(identity_path).st_mode & 0o777
- if current != 0o600:
- os.chmod(identity_path, 0o600)
- return RNS.Identity.from_file(identity_path)
- identity = RNS.Identity()
- identity.to_file(identity_path)
- os.chmod(identity_path, 0o600)
- return identity
-
-
-# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface.
-# Any other method/path is rejected here — CSRF cannot authenticate mesh callers
-# (the attacker controls both the "cookie" and the "form" side of the check), so
-# gating by whitelist is the only safe option.
-_RNS_ALLOWED = {("GET", "/api/sites")}
-
-
-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": ""}
- method = data.get("method", "GET")
- req_path = data.get("path", "/")
- if (method, req_path) not in _RNS_ALLOWED:
- return {
- "status": 403,
- "content_type": "text/plain; charset=utf-8",
- "body": "Forbidden: this endpoint is not available over Reticulum.",
- "headers": {},
- }
- return dispatch_request(data)
-
-
-def start_gateway(reticulum, bind_host="127.0.0.1"):
- GatewayState.reticulum = reticulum
- GatewayState.local_dispatch = dispatch_request
- HTTPServer.allow_reuse_address = True
- server = ThreadingHTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
- thread = threading.Thread(target=server.serve_forever, daemon=True)
- thread.start()
-
-
-def _config_settings_match(config_file, desired_host, desired_port):
- """Check if existing config transport and LoRa settings match desired values."""
- import configparser
- try:
- config = configparser.ConfigParser()
- config.read(config_file)
- # Check TCP transport
- tcp_enabled = get_setting("tcp_enabled", "1") == "1"
- has_tcp = config.has_section("TCP Transport")
- if tcp_enabled != has_tcp:
- return False
- if tcp_enabled and has_tcp:
- if (config.get("TCP Transport", "target_host") != desired_host or
- config.get("TCP Transport", "target_port") != str(desired_port)):
- return False
- # Check LoRa
- lora_enabled = get_setting("lora_enabled", "0") == "1"
- has_lora = config.has_section("RNode LoRa")
- if lora_enabled != has_lora:
- return False
- if lora_enabled and has_lora:
- if config.get("RNode LoRa", "port", fallback="") != get_setting("lora_port", ""):
- return False
- if config.get("RNode LoRa", "frequency", fallback="") != get_setting("lora_frequency", "867200000"):
- return False
- return True
- except Exception:
- pass
- return False
-
-
-def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
- """Generate a default Reticulum config with internet transport if none exists."""
- if config_dir is None:
- config_dir = os.path.expanduser("~/.reticulum")
- config_file = os.path.join(config_dir, "config")
- if transport_host is None:
- transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
- if transport_port is None:
- transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
-
- managed_sentinel = "# managed by tinyweb"
- if os.path.exists(config_file):
- try:
- with open(config_file) as f:
- existing = f.read()
- except OSError:
- existing = ""
- if managed_sentinel not in existing:
- # User-authored config — don't clobber it.
- if not _config_settings_match(config_file, transport_host, transport_port):
- print(
- f"Warning: {config_file} was not created by tinyweb; "
- "leaving it alone. Edit it manually to change transport/LoRa settings."
- )
- return
- if _config_settings_match(config_file, transport_host, transport_port):
- return
-
- # Build optional interface blocks
- tcp_block = ""
- if get_setting("tcp_enabled", "1") == "1":
- tcp_block = f"""
- [[TCP Transport]]
- type = TCPClientInterface
- enabled = yes
- target_host = {transport_host}
- target_port = {transport_port}
-"""
-
- lora_block = ""
- if get_setting("lora_enabled", "0") == "1":
- lora_port = get_setting("lora_port", "")
- if lora_port:
- lora_frequency = get_setting("lora_frequency", "867200000")
- lora_bandwidth = get_setting("lora_bandwidth", "125000")
- lora_txpower = get_setting("lora_txpower", "7")
- lora_sf = get_setting("lora_sf", "8")
- lora_cr = get_setting("lora_cr", "5")
- lora_block = f"""
- [[RNode LoRa]]
- type = RNodeInterface
- enabled = yes
- port = {lora_port}
- frequency = {lora_frequency}
- bandwidth = {lora_bandwidth}
- txpower = {lora_txpower}
- spreadingfactor = {lora_sf}
- codingrate = {lora_cr}
-"""
-
- os.makedirs(config_dir, exist_ok=True)
- with open(config_file, "w") as f:
- f.write(f"""{managed_sentinel}
-[reticulum]
- enable_transport = False
- share_instance = No
-
-[logging]
- loglevel = 4
-
-[interfaces]
- [[Default Interface]]
- type = AutoInterface
- enabled = Yes
-{tcp_block}{lora_block}""")
- print(f"Created Reticulum config at {config_file}")
-
-
-def _preload_embeddings():
- """Pre-load the embedding model and build the HNSW index in background."""
- if get_setting("semantic_search", "0") != "1":
- print("Semantic search disabled.")
- return
- try:
- from embeddings import _get_session, _get_reranker, build_index
- _get_session()
- build_index()
- if get_setting("use_reranker", "0") == "1":
- _get_reranker()
- print("Semantic search ready (with reranker).")
- else:
- print("Semantic search ready.")
- except Exception as e:
- print(f"Semantic search unavailable: {e}")
-
-
-def main():
- parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine")
- parser.add_argument("--version", "-v", action="store_true", help="Show version")
- parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)")
- parser.add_argument(
- "--bind", "-b", default="127.0.0.1",
- help="Address to bind the HTTP gateway to (default: 127.0.0.1). "
- "Use 0.0.0.0 to expose to the LAN; note that the web UI has no authentication.",
- )
- args = parser.parse_args()
-
- if args.version:
- print(f"TinyWeb {get_version()}")
- return
-
- bind_host = args.bind
- port = args.port or 8080
- gateway.GATEWAY_PORT = find_available_port(port, host=bind_host)
-
- init_db()
- transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
- transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
- threading.Thread(target=_preload_embeddings, daemon=True).start()
- config_dir = os.environ.get("RNS_CONFIG_DIR")
- ensure_rns_config(config_dir, transport_host, transport_port)
- reticulum = RNS.Reticulum(configdir=config_dir)
- identity = load_or_create_identity()
-
- destination = RNS.Destination(
- identity,
- RNS.Destination.IN,
- RNS.Destination.SINGLE,
- gateway.APP_NAME,
- *gateway.ASPECTS,
- )
-
- destination.register_request_handler(
- "/tinyweb",
- response_generator=rns_request_handler,
- allow=RNS.Destination.ALLOW_ALL,
- )
-
- # Initialize forum plugin if available
- forum = None
- try:
- from tinyweb_forum import ForumPlugin
- from db import get_site_name
- forum = ForumPlugin(DATA_DIR, identity, reticulum, site_name=get_site_name())
- if get_setting("forum_enabled", "0") == "1":
- forum.enable()
- templates_mod.FORUM_ENABLED = True
- handlers_mod.forum_plugin = forum
- print(f"Forum plugin: {'enabled' if forum.is_enabled() else 'available (enable in settings)'}")
- except ImportError:
- print("Forum plugin not installed (pip install tinyweb[forum])")
- except Exception as e:
- print(f"Forum plugin error: {e}")
-
- # Brief delay to ensure all interfaces (especially TCP) are fully ready
- time.sleep(2)
- destination.announce()
- set_setting("dest_hash", destination.hash.hex())
- start_gateway(reticulum, bind_host=bind_host)
-
- print(f"TinyWeb running!")
- if bind_host in ("0.0.0.0", "::"):
- print(f"Open http://localhost:{gateway.GATEWAY_PORT} in your browser")
- print(f"WARNING: listening on {bind_host} — the web UI has no authentication. "
- "Anyone on your network can control this instance.")
- else:
- print(f"Open http://{bind_host}:{gateway.GATEWAY_PORT} in your browser")
- print(f"Destination hash: {RNS.prettyhexrep(destination.hash)} (share this so friends can subscribe)")
-
- while True:
- time.sleep(1)
-
-
-if __name__ == "__main__":
- main()
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent / "src"))
+from tinyweb.app import main
+main()
diff --git a/conftest.py b/conftest.py
index 9a2f26e..4b5c8be 100644
--- a/conftest.py
+++ b/conftest.py
@@ -5,15 +5,11 @@ per-test tempfile, `seeded_db` layers sample rows on top, and `csrf_session`
primes the thread-local CSRF token that handlers read.
"""
import socket
-import sys
-from pathlib import Path
import pytest
-sys.path.insert(0, str(Path(__file__).parent))
-
-import db as db_module
-import handlers as handlers_module
+import tinyweb.db as db_module
+import tinyweb.handlers as handlers_module
@pytest.fixture
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6dfbe36
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,12 @@
+[project]
+name = "tinyweb"
+version = "0.1.0"
+description = "Personal decentralized search engine"
+requires-python = ">=3.10"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.backends._legacy:_Backend"
diff --git a/src/tinyweb/__init__.py b/src/tinyweb/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/tinyweb/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py
new file mode 100644
index 0000000..ca3b6de
--- /dev/null
+++ b/src/tinyweb/app.py
@@ -0,0 +1,312 @@
+import os
+import sys
+import time
+import threading
+import argparse
+import RNS
+from http.server import HTTPServer, ThreadingHTTPServer
+
+from tinyweb.db import init_db, get_setting, set_setting
+from tinyweb.handlers import dispatch_request
+import tinyweb.handlers as handlers_mod
+import tinyweb.templates as templates_mod
+import tinyweb.gateway
+from tinyweb.gateway import GatewayState, GatewayHandler
+
+IDENTITY_FILE = "tinyweb_identity"
+DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
+DEFAULT_TRANSPORT_PORT = 4242
+DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb")
+
+
+def get_transport_config():
+ host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))
+ return host, int(port)
+
+
+def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"):
+ """Find an available port starting from start."""
+ import socket
+ for port in range(start, start + max_attempts):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ s.bind((host, port))
+ return port
+ except OSError:
+ continue
+ return start
+
+
+def get_version():
+ """Get version from git tag or VERSION file."""
+ try:
+ import subprocess
+ tag = subprocess.check_output(
+ ["git", "describe", "--tags", "--abbrev=0"],
+ stderr=subprocess.DEVNULL,
+ text=True
+ ).strip()
+ if tag.startswith("v"):
+ return tag[1:]
+ return tag
+ except Exception:
+ version_file = os.path.join(os.path.dirname(__file__), "VERSION")
+ if os.path.exists(version_file):
+ with open(version_file) as f:
+ return f.read().strip()
+ return "0.0.0"
+
+
+def load_or_create_identity():
+ os.makedirs(DATA_DIR, exist_ok=True)
+ identity_path = os.path.join(DATA_DIR, IDENTITY_FILE)
+ if os.path.isfile(identity_path):
+ current = os.stat(identity_path).st_mode & 0o777
+ if current != 0o600:
+ os.chmod(identity_path, 0o600)
+ return RNS.Identity.from_file(identity_path)
+ identity = RNS.Identity()
+ identity.to_file(identity_path)
+ os.chmod(identity_path, 0o600)
+ return identity
+
+
+# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface.
+# Any other method/path is rejected here — CSRF cannot authenticate mesh callers
+# (the attacker controls both the "cookie" and the "form" side of the check), so
+# gating by whitelist is the only safe option.
+_RNS_ALLOWED = {("GET", "/api/sites")}
+
+
+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": ""}
+ method = data.get("method", "GET")
+ req_path = data.get("path", "/")
+ if (method, req_path) not in _RNS_ALLOWED:
+ return {
+ "status": 403,
+ "content_type": "text/plain; charset=utf-8",
+ "body": "Forbidden: this endpoint is not available over Reticulum.",
+ "headers": {},
+ }
+ return dispatch_request(data)
+
+
+def start_gateway(reticulum, bind_host="127.0.0.1"):
+ GatewayState.reticulum = reticulum
+ GatewayState.local_dispatch = dispatch_request
+ HTTPServer.allow_reuse_address = True
+ server = ThreadingHTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+
+def _config_settings_match(config_file, desired_host, desired_port):
+ """Check if existing config transport and LoRa settings match desired values."""
+ import configparser
+ try:
+ config = configparser.ConfigParser()
+ config.read(config_file)
+ # Check TCP transport
+ tcp_enabled = get_setting("tcp_enabled", "1") == "1"
+ has_tcp = config.has_section("TCP Transport")
+ if tcp_enabled != has_tcp:
+ return False
+ if tcp_enabled and has_tcp:
+ if (config.get("TCP Transport", "target_host") != desired_host or
+ config.get("TCP Transport", "target_port") != str(desired_port)):
+ return False
+ # Check LoRa
+ lora_enabled = get_setting("lora_enabled", "0") == "1"
+ has_lora = config.has_section("RNode LoRa")
+ if lora_enabled != has_lora:
+ return False
+ if lora_enabled and has_lora:
+ if config.get("RNode LoRa", "port", fallback="") != get_setting("lora_port", ""):
+ return False
+ if config.get("RNode LoRa", "frequency", fallback="") != get_setting("lora_frequency", "867200000"):
+ return False
+ return True
+ except Exception:
+ pass
+ return False
+
+
+def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
+ """Generate a default Reticulum config with internet transport if none exists."""
+ if config_dir is None:
+ config_dir = os.path.expanduser("~/.reticulum")
+ config_file = os.path.join(config_dir, "config")
+ if transport_host is None:
+ transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ if transport_port is None:
+ transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
+
+ managed_sentinel = "# managed by tinyweb"
+ if os.path.exists(config_file):
+ try:
+ with open(config_file) as f:
+ existing = f.read()
+ except OSError:
+ existing = ""
+ if managed_sentinel not in existing:
+ # User-authored config — don't clobber it.
+ if not _config_settings_match(config_file, transport_host, transport_port):
+ print(
+ f"Warning: {config_file} was not created by tinyweb; "
+ "leaving it alone. Edit it manually to change transport/LoRa settings."
+ )
+ return
+ if _config_settings_match(config_file, transport_host, transport_port):
+ return
+
+ # Build optional interface blocks
+ tcp_block = ""
+ if get_setting("tcp_enabled", "1") == "1":
+ tcp_block = f"""
+ [[TCP Transport]]
+ type = TCPClientInterface
+ enabled = yes
+ target_host = {transport_host}
+ target_port = {transport_port}
+"""
+
+ lora_block = ""
+ if get_setting("lora_enabled", "0") == "1":
+ lora_port = get_setting("lora_port", "")
+ if lora_port:
+ lora_frequency = get_setting("lora_frequency", "867200000")
+ lora_bandwidth = get_setting("lora_bandwidth", "125000")
+ lora_txpower = get_setting("lora_txpower", "7")
+ lora_sf = get_setting("lora_sf", "8")
+ lora_cr = get_setting("lora_cr", "5")
+ lora_block = f"""
+ [[RNode LoRa]]
+ type = RNodeInterface
+ enabled = yes
+ port = {lora_port}
+ frequency = {lora_frequency}
+ bandwidth = {lora_bandwidth}
+ txpower = {lora_txpower}
+ spreadingfactor = {lora_sf}
+ codingrate = {lora_cr}
+"""
+
+ os.makedirs(config_dir, exist_ok=True)
+ with open(config_file, "w") as f:
+ f.write(f"""{managed_sentinel}
+[reticulum]
+ enable_transport = False
+ share_instance = No
+
+[logging]
+ loglevel = 4
+
+[interfaces]
+ [[Default Interface]]
+ type = AutoInterface
+ enabled = Yes
+{tcp_block}{lora_block}""")
+ print(f"Created Reticulum config at {config_file}")
+
+
+def _preload_embeddings():
+ """Pre-load the embedding model and build the HNSW index in background."""
+ if get_setting("semantic_search", "0") != "1":
+ print("Semantic search disabled.")
+ return
+ try:
+ from tinyweb.embeddings import _get_session, _get_reranker, build_index
+ _get_session()
+ build_index()
+ if get_setting("use_reranker", "0") == "1":
+ _get_reranker()
+ print("Semantic search ready (with reranker).")
+ else:
+ print("Semantic search ready.")
+ except Exception as e:
+ print(f"Semantic search unavailable: {e}")
+
+
+def main():
+ parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine")
+ parser.add_argument("--version", "-v", action="store_true", help="Show version")
+ parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)")
+ parser.add_argument(
+ "--bind", "-b", default="127.0.0.1",
+ help="Address to bind the HTTP gateway to (default: 127.0.0.1). "
+ "Use 0.0.0.0 to expose to the LAN; note that the web UI has no authentication.",
+ )
+ args = parser.parse_args()
+
+ if args.version:
+ print(f"TinyWeb {get_version()}")
+ return
+
+ bind_host = args.bind
+ port = args.port or 8080
+ gateway.GATEWAY_PORT = find_available_port(port, host=bind_host)
+
+ init_db()
+ transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
+ threading.Thread(target=_preload_embeddings, daemon=True).start()
+ config_dir = os.environ.get("RNS_CONFIG_DIR")
+ ensure_rns_config(config_dir, transport_host, transport_port)
+ reticulum = RNS.Reticulum(configdir=config_dir)
+ identity = load_or_create_identity()
+
+ destination = RNS.Destination(
+ identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ gateway.APP_NAME,
+ *gateway.ASPECTS,
+ )
+
+ destination.register_request_handler(
+ "/tinyweb",
+ response_generator=rns_request_handler,
+ allow=RNS.Destination.ALLOW_ALL,
+ )
+
+ # Initialize forum plugin if available
+ forum = None
+ try:
+ from tinyweb_forum import ForumPlugin
+ from tinyweb.db import get_site_name
+ forum = ForumPlugin(DATA_DIR, identity, reticulum, site_name=get_site_name())
+ if get_setting("forum_enabled", "0") == "1":
+ forum.enable()
+ templates_mod.FORUM_ENABLED = True
+ handlers_mod.forum_plugin = forum
+ print(f"Forum plugin: {'enabled' if forum.is_enabled() else 'available (enable in settings)'}")
+ except ImportError:
+ print("Forum plugin not installed (pip install tinyweb[forum])")
+ except Exception as e:
+ print(f"Forum plugin error: {e}")
+
+ # Brief delay to ensure all interfaces (especially TCP) are fully ready
+ time.sleep(2)
+ destination.announce()
+ set_setting("dest_hash", destination.hash.hex())
+ start_gateway(reticulum, bind_host=bind_host)
+
+ print(f"TinyWeb running!")
+ if bind_host in ("0.0.0.0", "::"):
+ print(f"Open http://localhost:{gateway.GATEWAY_PORT} in your browser")
+ print(f"WARNING: listening on {bind_host} — the web UI has no authentication. "
+ "Anyone on your network can control this instance.")
+ else:
+ print(f"Open http://{bind_host}:{gateway.GATEWAY_PORT} in your browser")
+ print(f"Destination hash: {RNS.prettyhexrep(destination.hash)} (share this so friends can subscribe)")
+
+ while True:
+ time.sleep(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/db.py b/src/tinyweb/db.py
similarity index 99%
rename from db.py
rename to src/tinyweb/db.py
index 97378d9..d7f269b 100644
--- a/db.py
+++ b/src/tinyweb/db.py
@@ -440,7 +440,7 @@ def index_url(url, note="", reticulum_dest=""):
db.commit()
if get_setting("semantic_search", "0") == "1":
try:
- from embeddings import store_embeddings
+ from tinyweb.embeddings import store_embeddings
store_embeddings(page_id, title, body, db)
except Exception:
pass # embedding generation is best-effort
diff --git a/embeddings.py b/src/tinyweb/embeddings.py
similarity index 98%
rename from embeddings.py
rename to src/tinyweb/embeddings.py
index 03f6f13..2aecee4 100644
--- a/embeddings.py
+++ b/src/tinyweb/embeddings.py
@@ -246,7 +246,7 @@ def embed(texts, is_query=False):
def _maybe_compress(embeddings):
"""Compress embeddings to float16 if compression is enabled."""
try:
- from db import get_setting
+ from tinyweb.db import get_setting
if get_setting("compress_embeddings", "0") == "1":
return embeddings.astype(np.float16)
except Exception:
@@ -279,7 +279,7 @@ def build_index(db=None):
import hnswlib
global _hnsw_index, _hnsw_ids
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
@@ -428,7 +428,7 @@ def semantic_search(query_text, limit=100, db=None):
scores = [1.0 - float(d) for d in distances[0]]
# Fetch chunk details from DB
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
@@ -501,7 +501,7 @@ def hybrid_search(query_text, bm25_ranked_ids, limit=10, db=None, use_reranker=F
rerank_ids = all_ids[:20]
tail_ids = all_ids[20:30]
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
@@ -553,7 +553,7 @@ def hybrid_search(query_text, bm25_ranked_ids, limit=10, db=None, use_reranker=F
def reindex_all(db=None, progress_callback=None):
"""Re-embed all pages and regenerate all summaries. Rebuilds HNSW index."""
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
diff --git a/gateway.py b/src/tinyweb/gateway.py
similarity index 99%
rename from gateway.py
rename to src/tinyweb/gateway.py
index fa4b076..5b292ff 100644
--- a/gateway.py
+++ b/src/tinyweb/gateway.py
@@ -201,7 +201,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
def main():
if len(sys.argv) < 2:
- print(f"Usage: python gateway.py ")
+ print(f"Usage: python -m tinyweb.gateway ")
print(f" The destination hash is printed by app.py on startup.")
sys.exit(1)
diff --git a/handlers/__init__.py b/src/tinyweb/handlers/__init__.py
similarity index 97%
rename from handlers/__init__.py
rename to src/tinyweb/handlers/__init__.py
index 228508e..524ae8d 100644
--- a/handlers/__init__.py
+++ b/src/tinyweb/handlers/__init__.py
@@ -3,10 +3,10 @@ import secrets
import threading
from urllib.parse import unquote
-from db import get_db, return_db, set_setting
-import templates as templates_mod
-from templates import esc, wrap_page
-from rns_client import fetch_remote_sites
+from tinyweb.db import get_db, return_db, set_setting
+import tinyweb.templates as templates_mod
+from tinyweb.templates import esc, wrap_page
+from tinyweb.rns_client import fetch_remote_sites
from ._helpers import (
_request_local, _get_csrf_token, _csrf_field, _check_csrf,
@@ -132,7 +132,7 @@ def _dispatch_inner(data):
_set_flash("Template reset to default.")
return _redirect("/style")
elif path == "/style/vacuum":
- from db import vacuum_db
+ from tinyweb.db import vacuum_db
vacuum_db()
_set_flash("Database vacuumed.")
return _redirect("/style")
diff --git a/handlers/_helpers.py b/src/tinyweb/handlers/_helpers.py
similarity index 97%
rename from handlers/_helpers.py
rename to src/tinyweb/handlers/_helpers.py
index 2611ce7..c7e8d83 100644
--- a/handlers/_helpers.py
+++ b/src/tinyweb/handlers/_helpers.py
@@ -3,8 +3,8 @@ import re
import secrets
import threading
-from db import get_db, return_db, get_setting, set_setting
-from templates import wrap_page
+from tinyweb.db import get_db, return_db, get_setting, set_setting
+from tinyweb.templates import wrap_page
_request_local = threading.local()
diff --git a/handlers/customize.py b/src/tinyweb/handlers/customize.py
similarity index 98%
rename from handlers/customize.py
rename to src/tinyweb/handlers/customize.py
index 6b3786d..959476d 100644
--- a/handlers/customize.py
+++ b/src/tinyweb/handlers/customize.py
@@ -1,6 +1,6 @@
-from db import get_db, return_db, get_setting, set_setting, get_site_name
-import templates as templates_mod
-from templates import esc, DEFAULT_TEMPLATE
+from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name
+import tinyweb.templates as templates_mod
+from tinyweb.templates import esc, DEFAULT_TEMPLATE
from ._helpers import _respond, _redirect, _json_response, _csrf_field, _get_bookmark_token, _request_local
from .subscriptions import _count_shared_pages
@@ -48,7 +48,7 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
lora_sf = get_setting("lora_sf", "8")
lora_cr = get_setting("lora_cr", "5")
csrf = _csrf_field()
- from handlers import forum_plugin as _fp
+ from tinyweb.handlers import forum_plugin as _fp
if _fp is not None:
forum_body = (
f""
@@ -223,7 +223,7 @@ def handle_style_submit(body, gateway_host="", scheme="http"):
forum_enabled = "1" if body.get("forum_enabled") else "0"
current_forum = get_setting("forum_enabled", "0")
if forum_enabled != current_forum:
- from handlers import forum_plugin
+ from tinyweb.handlers import forum_plugin
if forum_enabled == "1" and forum_plugin is None:
_set_flash("Forum plugin not installed. Run: pip install tinyweb-forum")
return _redirect("/style")
@@ -288,7 +288,7 @@ def handle_field_save(body):
if not key:
return _json_response({"status": "error", "message": "No key provided."}, 400)
if key == "forum_enabled":
- from handlers import forum_plugin
+ from tinyweb.handlers import forum_plugin
if value == "1" and forum_plugin is None:
return _json_response({"status": "error", "message": "Forum plugin not installed."}, 400)
if value == "1":
diff --git a/handlers/data.py b/src/tinyweb/handlers/data.py
similarity index 95%
rename from handlers/data.py
rename to src/tinyweb/handlers/data.py
index d3a714f..c16a65f 100644
--- a/handlers/data.py
+++ b/src/tinyweb/handlers/data.py
@@ -1,8 +1,8 @@
import json
import threading
-from db import get_db, return_db, get_setting, set_setting, index_url
-from templates import esc
+from tinyweb.db import get_db, return_db, get_setting, set_setting, index_url
+from tinyweb.templates import esc
from ._helpers import _respond, _json_response, _redirect, _csrf_field
MAX_EXPORT = 10000
@@ -111,7 +111,7 @@ def handle_reindex_submit(body):
def _run():
try:
- from embeddings import reindex_all
+ from tinyweb.embeddings import reindex_all
def progress(current, total):
set_setting("reindex_progress", f"{current}/{total}")
reindex_all(progress_callback=progress)
diff --git a/handlers/pages.py b/src/tinyweb/handlers/pages.py
similarity index 98%
rename from handlers/pages.py
rename to src/tinyweb/handlers/pages.py
index 0799251..4eb2a12 100644
--- a/handlers/pages.py
+++ b/src/tinyweb/handlers/pages.py
@@ -3,8 +3,8 @@ import json
import secrets
from urllib.parse import unquote
-from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
-from templates import esc
+from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
+from tinyweb.templates import esc
from ._helpers import (
_csrf_field, _respond, _redirect, _error,
_paginate, _page_nav, _get_page_tags, _set_page_tags, _cleanup_orphaned_tags,
@@ -137,7 +137,7 @@ def handle_add_manual_submit(body):
if get_setting("semantic_search", "0") == "1":
try:
- from embeddings import store_embeddings
+ from tinyweb.embeddings import store_embeddings
store_embeddings(page_id, manual_title, manual_desc, db)
db.commit()
except Exception as e:
diff --git a/handlers/search.py b/src/tinyweb/handlers/search.py
similarity index 97%
rename from handlers/search.py
rename to src/tinyweb/handlers/search.py
index d9ee7c1..951b4d4 100644
--- a/handlers/search.py
+++ b/src/tinyweb/handlers/search.py
@@ -1,5 +1,5 @@
-from db import get_db, return_db, get_setting, get_site_name, clean_url
-from templates import esc
+from tinyweb.db import get_db, return_db, get_setting, get_site_name, clean_url
+from tinyweb.templates import esc
from ._helpers import _sanitize_fts_query, _paginate, _get_page_tags, _respond, _page_nav, PER_PAGE
@@ -31,7 +31,7 @@ def handle_search(query):
chunk_snippets = {}
if get_setting("semantic_search", "0") == "1":
try:
- from embeddings import hybrid_search
+ from tinyweb.embeddings import hybrid_search
use_reranker = get_setting("use_reranker", "1") == "1"
fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
fused_ids = [pid for pid, _ in fused]
diff --git a/handlers/subscriptions.py b/src/tinyweb/handlers/subscriptions.py
similarity index 98%
rename from handlers/subscriptions.py
rename to src/tinyweb/handlers/subscriptions.py
index 97f20b2..0113d07 100644
--- a/handlers/subscriptions.py
+++ b/src/tinyweb/handlers/subscriptions.py
@@ -1,9 +1,9 @@
import threading
from datetime import datetime
-from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
-from templates import esc
-from rns_client import fetch_remote_sites
+from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
+from tinyweb.templates import esc
+from tinyweb.rns_client import fetch_remote_sites
from ._helpers import (
_get_page_tags, _respond, _redirect, _json_response, _error,
_csrf_field,
@@ -389,7 +389,7 @@ def _sync_subscription(sub_id):
)
if get_setting("semantic_search", "0") == "1":
try:
- from embeddings import store_remote_embeddings
+ from tinyweb.embeddings import store_remote_embeddings
rp_id = db.execute(
"SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
(sub_id, s["url"]),
diff --git a/handlers/tags.py b/src/tinyweb/handlers/tags.py
similarity index 96%
rename from handlers/tags.py
rename to src/tinyweb/handlers/tags.py
index f0e927d..46189d6 100644
--- a/handlers/tags.py
+++ b/src/tinyweb/handlers/tags.py
@@ -1,5 +1,5 @@
-from db import get_db, return_db
-from templates import esc
+from tinyweb.db import get_db, return_db
+from tinyweb.templates import esc
from ._helpers import _respond, _paginate, _page_nav, _get_page_tags, BROWSE_PER_PAGE
diff --git a/rns_client.py b/src/tinyweb/rns_client.py
similarity index 100%
rename from rns_client.py
rename to src/tinyweb/rns_client.py
diff --git a/templates.py b/src/tinyweb/templates.py
similarity index 97%
rename from templates.py
rename to src/tinyweb/templates.py
index 27a1a6b..8208211 100644
--- a/templates.py
+++ b/src/tinyweb/templates.py
@@ -1,5 +1,5 @@
import html
-from db import get_setting
+from tinyweb.db import get_setting
FORUM_ENABLED = False
diff --git a/tests/test_csrf.py b/tests/test_csrf.py
index 43b4487..82f2584 100644
--- a/tests/test_csrf.py
+++ b/tests/test_csrf.py
@@ -4,8 +4,8 @@ Every POST handler calls this to verify the submitted _csrf field matches
the token stored in the thread-local (which is seeded from the cookie by
`dispatch_request`). Missing or mismatched tokens must fail closed.
"""
-import handlers as handlers_module
-from handlers import _check_csrf, _csrf_field, _get_csrf_token
+import tinyweb.handlers as handlers_module
+from tinyweb.handlers import _check_csrf, _csrf_field, _get_csrf_token
def _set_token(token):
diff --git a/tests/test_db_index_url.py b/tests/test_db_index_url.py
index 50f73ce..aac60a5 100644
--- a/tests/test_db_index_url.py
+++ b/tests/test_db_index_url.py
@@ -6,8 +6,8 @@ in sync via triggers, and the connection pool returning clean connections.
from unittest.mock import patch
from conftest import patch_dns_ok
-import db as db_module
-from db import get_db, return_db, index_url
+import tinyweb.db as db_module
+from tinyweb.db import get_db, return_db, index_url
def _mock_fetch_page(title="Test Page", body="test body text", links=None, meta=""):
diff --git a/tests/test_db_schema.py b/tests/test_db_schema.py
index 5a4f77c..4bc6691 100644
--- a/tests/test_db_schema.py
+++ b/tests/test_db_schema.py
@@ -3,7 +3,7 @@
`init_db` is called unconditionally on startup, so it must be idempotent
and create every table/trigger the rest of the app expects.
"""
-from db import get_db, return_db, init_db, get_setting, set_setting, get_site_name
+from tinyweb.db import get_db, return_db, init_db, get_setting, set_setting, get_site_name
EXPECTED_TABLES = {
diff --git a/tests/test_fts_sanitizer.py b/tests/test_fts_sanitizer.py
index ad061da..08afb6c 100644
--- a/tests/test_fts_sanitizer.py
+++ b/tests/test_fts_sanitizer.py
@@ -6,7 +6,7 @@ could escape the quoting. These tests keep that regression dead.
"""
import pytest
-from handlers import _sanitize_fts_query
+from tinyweb.handlers import _sanitize_fts_query
def test_empty_query_returns_no_match_token():
diff --git a/tests/test_gateway_limits.py b/tests/test_gateway_limits.py
index 6033c3a..a772968 100644
--- a/tests/test_gateway_limits.py
+++ b/tests/test_gateway_limits.py
@@ -8,8 +8,8 @@ import io
import pytest
-import app as app_module
-from gateway import GatewayHandler, MAX_BODY_SIZE
+from tinyweb import app as app_module
+from tinyweb.gateway import GatewayHandler, MAX_BODY_SIZE
class FakeHeaders:
@@ -72,7 +72,7 @@ def test_post_at_size_cap_accepted():
rfile=io.BytesIO(b""),
)
# Stub out local_dispatch so _forward doesn't try the network path.
- from gateway import GatewayState
+ from tinyweb.gateway import GatewayState
original = GatewayState.local_dispatch
GatewayState.local_dispatch = lambda data: {
"status": 404, "content_type": "text/plain", "body": "nope",
diff --git a/tests/test_handlers_pages.py b/tests/test_handlers_pages.py
index ab4704c..bc80ad0 100644
--- a/tests/test_handlers_pages.py
+++ b/tests/test_handlers_pages.py
@@ -4,8 +4,8 @@ The bulk-delete confirmation flow is a data-loss guard added in commit
8dffd8c — a stray POST without `confirmed=1` must render the confirmation
page instead of actually deleting.
"""
-from db import get_db, return_db
-from handlers import (
+from tinyweb.db import get_db, return_db
+from tinyweb.handlers import (
handle_bulk_action,
handle_edit_form,
handle_edit_submit,
diff --git a/tests/test_handlers_search.py b/tests/test_handlers_search.py
index f7d2f9e..3f4fb14 100644
--- a/tests/test_handlers_search.py
+++ b/tests/test_handlers_search.py
@@ -1,5 +1,5 @@
"""Tests for `handle_search` — the home page + primary user flow."""
-from handlers import handle_search
+from tinyweb.handlers import handle_search
def test_empty_index_empty_query_shows_welcome(temp_db, csrf_session):
diff --git a/tests/test_handlers_subs.py b/tests/test_handlers_subs.py
index 93ee97d..c24ea7b 100644
--- a/tests/test_handlers_subs.py
+++ b/tests/test_handlers_subs.py
@@ -6,9 +6,9 @@ available and falls back to a live fetch otherwise.
"""
from unittest.mock import patch
-import handlers as handlers_module
-from db import get_db, return_db
-from handlers import handle_subscription_add, handle_subscription_browse
+import tinyweb.handlers as handlers_module
+from tinyweb.db import get_db, return_db
+from tinyweb.handlers import handle_subscription_add, handle_subscription_browse
VALID_HASH = "a" * 32
diff --git a/tests/test_handlers_tags.py b/tests/test_handlers_tags.py
index 7ec8f05..3afdad7 100644
--- a/tests/test_handlers_tags.py
+++ b/tests/test_handlers_tags.py
@@ -4,8 +4,8 @@ Tags are stored via a join table, so orphaned rows in `tags` can accumulate
if `_cleanup_orphaned_tags` isn't called after deletion/retagging. Tag
counts shown in the UI rely on this being right.
"""
-from db import get_db, return_db
-from handlers import (
+from tinyweb.db import get_db, return_db
+from tinyweb.handlers import (
_cleanup_orphaned_tags,
_get_page_tags,
_set_page_tags,
diff --git a/tests/test_link_extraction.py b/tests/test_link_extraction.py
index 2d8c741..0baba34 100644
--- a/tests/test_link_extraction.py
+++ b/tests/test_link_extraction.py
@@ -7,7 +7,7 @@ skip Wikipedia special pages, resolve relatives via urljoin.
from unittest.mock import patch
from conftest import patch_dns_ok
-import db as db_module
+import tinyweb.db as db_module
class FakeResponse:
diff --git a/tests/test_pagination.py b/tests/test_pagination.py
index 05077e0..6b6f727 100644
--- a/tests/test_pagination.py
+++ b/tests/test_pagination.py
@@ -1,5 +1,5 @@
"""Tests for `_paginate` and `_page_nav`."""
-from handlers import _paginate, _page_nav, PER_PAGE
+from tinyweb.handlers import _paginate, _page_nav, PER_PAGE
def test_paginate_default_is_one():
diff --git a/tests/test_regressions.py b/tests/test_regressions.py
index f8a5df7..eeab752 100644
--- a/tests/test_regressions.py
+++ b/tests/test_regressions.py
@@ -14,12 +14,12 @@ from unittest.mock import patch
import pytest
-import app as app_module
-import db as db_module
-import handlers as handlers_module
+from tinyweb import app as app_module
+import tinyweb.db as db_module
+import tinyweb.handlers as handlers_module
from conftest import patch_dns_fail, patch_dns_ok
-from db import clean_url
-from handlers import _sanitize_fts_query, handle_bulk_action
+from tinyweb.db import clean_url
+from tinyweb.handlers import _sanitize_fts_query, handle_bulk_action
def test_6ffd38d_clean_url_preserves_www_when_bare_domain_fails(monkeypatch):
@@ -47,7 +47,7 @@ def test_1bc695f_fts_sanitizer_drops_operator_words(op):
def test_1bc695f_gateway_rejects_oversize_body():
"""1bc695f: 16 MiB body-size cap prevents memory-exhaustion DoS."""
from tests.test_gateway_limits import FakeGatewayHandler
- from gateway import MAX_BODY_SIZE
+ from tinyweb.gateway import MAX_BODY_SIZE
h = FakeGatewayHandler(
path="/add", method="POST",
headers={"Content-Length": str(MAX_BODY_SIZE + 1)},
@@ -70,7 +70,7 @@ def test_1bc695f_mesh_rejects_non_whitelisted_paths():
def test_1bc695f_pool_returns_clean_connection(temp_db, monkeypatch):
"""1bc695f: uncommitted transactions on a pooled connection used to leak
into the next consumer."""
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
db = get_db()
db.execute(
"INSERT INTO pages (url, title, body) VALUES (?, ?, ?)",
@@ -88,7 +88,7 @@ def test_1bc695f_pool_returns_clean_connection(temp_db, monkeypatch):
def test_8dffd8c_bulk_delete_requires_confirmation(seeded_db, csrf_session):
"""8dffd8c: bulk delete without confirmed=1 must render a confirm page
instead of deleting — the JS confirm on /pages is a first-line filter only."""
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
db = get_db()
try:
pid = db.execute("SELECT id FROM pages LIMIT 1").fetchone()["id"]
diff --git a/tests/test_sharing_logic.py b/tests/test_sharing_logic.py
index c9c06d4..36dca46 100644
--- a/tests/test_sharing_logic.py
+++ b/tests/test_sharing_logic.py
@@ -6,7 +6,7 @@ hiding pages the user meant to share — both are worth a regression net.
"""
import pytest
-from handlers import _page_is_shared
+from tinyweb.handlers import _page_is_shared
@pytest.mark.parametrize("mode", ["exclude_private", "require_public"])
diff --git a/tests/test_ssrf.py b/tests/test_ssrf.py
index 807f9bd..31eb132 100644
--- a/tests/test_ssrf.py
+++ b/tests/test_ssrf.py
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
-from db import _validate_url_target
+from tinyweb.db import _validate_url_target
def _mock_getaddrinfo(address):
diff --git a/tests/test_url_cleanup.py b/tests/test_url_cleanup.py
index 1eef72b..8ade28b 100644
--- a/tests/test_url_cleanup.py
+++ b/tests/test_url_cleanup.py
@@ -6,7 +6,7 @@ this function can silently cause duplicate rows or mask legitimate saves.
import pytest
from conftest import patch_dns_ok, patch_dns_fail
-from db import clean_url, TRACKING_PARAMS
+from tinyweb.db import clean_url, TRACKING_PARAMS
def test_strips_fragment(monkeypatch):
From 76672aa838be424ba508153dfcb3b6a7a1f0760d Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 17 Jun 2026 05:03:23 +0000
Subject: [PATCH 178/194] src layout: move core code into src/tinyweb/ package
- Moved app.py, db.py, gateway.py, templates.py, embeddings.py,
rns_client.py, and handlers/ into src/tinyweb/
- Created root app.py shim (adds src/ to sys.path, imports main)
- Created pyproject.toml with setuptools config (where = ["src"])
- Added src/tinyweb/__init__.py
- Updated all internal imports to use tinyweb. prefix (73 occurrences)
- Removed sys.path.insert hack from conftest.py
- Updated Dockerfile: pip install -e /app before running
- Updated gateway.py usage message: python -m tinyweb.gateway
- Updated README.md gateway usage instructions
---
Dockerfile | 2 +
README.md | 2 +-
app.py | 315 +-----------------
conftest.py | 8 +-
pyproject.toml | 12 +
src/tinyweb/__init__.py | 1 +
src/tinyweb/app.py | 312 +++++++++++++++++
db.py => src/tinyweb/db.py | 2 +-
embeddings.py => src/tinyweb/embeddings.py | 10 +-
gateway.py => src/tinyweb/gateway.py | 2 +-
.../tinyweb/handlers}/__init__.py | 10 +-
.../tinyweb/handlers}/_helpers.py | 4 +-
.../tinyweb/handlers}/customize.py | 12 +-
{handlers => src/tinyweb/handlers}/data.py | 6 +-
{handlers => src/tinyweb/handlers}/pages.py | 6 +-
{handlers => src/tinyweb/handlers}/search.py | 6 +-
.../tinyweb/handlers}/subscriptions.py | 8 +-
{handlers => src/tinyweb/handlers}/tags.py | 4 +-
rns_client.py => src/tinyweb/rns_client.py | 0
templates.py => src/tinyweb/templates.py | 2 +-
tests/test_csrf.py | 4 +-
tests/test_db_index_url.py | 4 +-
tests/test_db_schema.py | 2 +-
tests/test_fts_sanitizer.py | 2 +-
tests/test_gateway_limits.py | 6 +-
tests/test_handlers_pages.py | 4 +-
tests/test_handlers_search.py | 2 +-
tests/test_handlers_subs.py | 6 +-
tests/test_handlers_tags.py | 4 +-
tests/test_link_extraction.py | 2 +-
tests/test_pagination.py | 2 +-
tests/test_regressions.py | 16 +-
tests/test_sharing_logic.py | 2 +-
tests/test_ssrf.py | 2 +-
tests/test_url_cleanup.py | 2 +-
35 files changed, 400 insertions(+), 384 deletions(-)
create mode 100644 pyproject.toml
create mode 100644 src/tinyweb/__init__.py
create mode 100644 src/tinyweb/app.py
rename db.py => src/tinyweb/db.py (99%)
rename embeddings.py => src/tinyweb/embeddings.py (98%)
rename gateway.py => src/tinyweb/gateway.py (99%)
rename {handlers => src/tinyweb/handlers}/__init__.py (97%)
rename {handlers => src/tinyweb/handlers}/_helpers.py (97%)
rename {handlers => src/tinyweb/handlers}/customize.py (98%)
rename {handlers => src/tinyweb/handlers}/data.py (95%)
rename {handlers => src/tinyweb/handlers}/pages.py (98%)
rename {handlers => src/tinyweb/handlers}/search.py (97%)
rename {handlers => src/tinyweb/handlers}/subscriptions.py (98%)
rename {handlers => src/tinyweb/handlers}/tags.py (96%)
rename rns_client.py => src/tinyweb/rns_client.py (100%)
rename templates.py => src/tinyweb/templates.py (97%)
diff --git a/Dockerfile b/Dockerfile
index 3f73263..de57fda 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,6 +13,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
+RUN pip install -e /app
+
RUN mkdir -p /data
ENV PYTHONUNBUFFERED=1
diff --git a/README.md b/README.md
index 991c981..c092eda 100644
--- a/README.md
+++ b/README.md
@@ -163,7 +163,7 @@ The `/export` page produces a JSON dump of your pages. It's a migration aid —
To browse a remote TinyWeb instance without running your own index:
```bash
-python gateway.py
+python -m tinyweb.gateway
```
This connects over Reticulum and serves the remote instance at `http://localhost:8080`.
diff --git a/app.py b/app.py
index 035eca0..a5bf38b 100644
--- a/app.py
+++ b/app.py
@@ -1,312 +1,5 @@
-import os
import sys
-import time
-import threading
-import argparse
-import RNS
-from http.server import HTTPServer, ThreadingHTTPServer
-
-from db import init_db, get_setting, set_setting
-from handlers import dispatch_request
-import handlers as handlers_mod
-import templates as templates_mod
-import gateway
-from gateway import GatewayState, GatewayHandler
-
-IDENTITY_FILE = "tinyweb_identity"
-DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
-DEFAULT_TRANSPORT_PORT = 4242
-DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb")
-
-
-def get_transport_config():
- host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
- port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))
- return host, int(port)
-
-
-def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"):
- """Find an available port starting from start."""
- import socket
- for port in range(start, start + max_attempts):
- try:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- s.bind((host, port))
- return port
- except OSError:
- continue
- return start
-
-
-def get_version():
- """Get version from git tag or VERSION file."""
- try:
- import subprocess
- tag = subprocess.check_output(
- ["git", "describe", "--tags", "--abbrev=0"],
- stderr=subprocess.DEVNULL,
- text=True
- ).strip()
- if tag.startswith("v"):
- return tag[1:]
- return tag
- except Exception:
- version_file = os.path.join(os.path.dirname(__file__), "VERSION")
- if os.path.exists(version_file):
- with open(version_file) as f:
- return f.read().strip()
- return "0.0.0"
-
-
-def load_or_create_identity():
- os.makedirs(DATA_DIR, exist_ok=True)
- identity_path = os.path.join(DATA_DIR, IDENTITY_FILE)
- if os.path.isfile(identity_path):
- current = os.stat(identity_path).st_mode & 0o777
- if current != 0o600:
- os.chmod(identity_path, 0o600)
- return RNS.Identity.from_file(identity_path)
- identity = RNS.Identity()
- identity.to_file(identity_path)
- os.chmod(identity_path, 0o600)
- return identity
-
-
-# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface.
-# Any other method/path is rejected here — CSRF cannot authenticate mesh callers
-# (the attacker controls both the "cookie" and the "form" side of the check), so
-# gating by whitelist is the only safe option.
-_RNS_ALLOWED = {("GET", "/api/sites")}
-
-
-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": ""}
- method = data.get("method", "GET")
- req_path = data.get("path", "/")
- if (method, req_path) not in _RNS_ALLOWED:
- return {
- "status": 403,
- "content_type": "text/plain; charset=utf-8",
- "body": "Forbidden: this endpoint is not available over Reticulum.",
- "headers": {},
- }
- return dispatch_request(data)
-
-
-def start_gateway(reticulum, bind_host="127.0.0.1"):
- GatewayState.reticulum = reticulum
- GatewayState.local_dispatch = dispatch_request
- HTTPServer.allow_reuse_address = True
- server = ThreadingHTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
- thread = threading.Thread(target=server.serve_forever, daemon=True)
- thread.start()
-
-
-def _config_settings_match(config_file, desired_host, desired_port):
- """Check if existing config transport and LoRa settings match desired values."""
- import configparser
- try:
- config = configparser.ConfigParser()
- config.read(config_file)
- # Check TCP transport
- tcp_enabled = get_setting("tcp_enabled", "1") == "1"
- has_tcp = config.has_section("TCP Transport")
- if tcp_enabled != has_tcp:
- return False
- if tcp_enabled and has_tcp:
- if (config.get("TCP Transport", "target_host") != desired_host or
- config.get("TCP Transport", "target_port") != str(desired_port)):
- return False
- # Check LoRa
- lora_enabled = get_setting("lora_enabled", "0") == "1"
- has_lora = config.has_section("RNode LoRa")
- if lora_enabled != has_lora:
- return False
- if lora_enabled and has_lora:
- if config.get("RNode LoRa", "port", fallback="") != get_setting("lora_port", ""):
- return False
- if config.get("RNode LoRa", "frequency", fallback="") != get_setting("lora_frequency", "867200000"):
- return False
- return True
- except Exception:
- pass
- return False
-
-
-def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
- """Generate a default Reticulum config with internet transport if none exists."""
- if config_dir is None:
- config_dir = os.path.expanduser("~/.reticulum")
- config_file = os.path.join(config_dir, "config")
- if transport_host is None:
- transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
- if transport_port is None:
- transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
-
- managed_sentinel = "# managed by tinyweb"
- if os.path.exists(config_file):
- try:
- with open(config_file) as f:
- existing = f.read()
- except OSError:
- existing = ""
- if managed_sentinel not in existing:
- # User-authored config — don't clobber it.
- if not _config_settings_match(config_file, transport_host, transport_port):
- print(
- f"Warning: {config_file} was not created by tinyweb; "
- "leaving it alone. Edit it manually to change transport/LoRa settings."
- )
- return
- if _config_settings_match(config_file, transport_host, transport_port):
- return
-
- # Build optional interface blocks
- tcp_block = ""
- if get_setting("tcp_enabled", "1") == "1":
- tcp_block = f"""
- [[TCP Transport]]
- type = TCPClientInterface
- enabled = yes
- target_host = {transport_host}
- target_port = {transport_port}
-"""
-
- lora_block = ""
- if get_setting("lora_enabled", "0") == "1":
- lora_port = get_setting("lora_port", "")
- if lora_port:
- lora_frequency = get_setting("lora_frequency", "867200000")
- lora_bandwidth = get_setting("lora_bandwidth", "125000")
- lora_txpower = get_setting("lora_txpower", "7")
- lora_sf = get_setting("lora_sf", "8")
- lora_cr = get_setting("lora_cr", "5")
- lora_block = f"""
- [[RNode LoRa]]
- type = RNodeInterface
- enabled = yes
- port = {lora_port}
- frequency = {lora_frequency}
- bandwidth = {lora_bandwidth}
- txpower = {lora_txpower}
- spreadingfactor = {lora_sf}
- codingrate = {lora_cr}
-"""
-
- os.makedirs(config_dir, exist_ok=True)
- with open(config_file, "w") as f:
- f.write(f"""{managed_sentinel}
-[reticulum]
- enable_transport = False
- share_instance = No
-
-[logging]
- loglevel = 4
-
-[interfaces]
- [[Default Interface]]
- type = AutoInterface
- enabled = Yes
-{tcp_block}{lora_block}""")
- print(f"Created Reticulum config at {config_file}")
-
-
-def _preload_embeddings():
- """Pre-load the embedding model and build the HNSW index in background."""
- if get_setting("semantic_search", "0") != "1":
- print("Semantic search disabled.")
- return
- try:
- from embeddings import _get_session, _get_reranker, build_index
- _get_session()
- build_index()
- if get_setting("use_reranker", "0") == "1":
- _get_reranker()
- print("Semantic search ready (with reranker).")
- else:
- print("Semantic search ready.")
- except Exception as e:
- print(f"Semantic search unavailable: {e}")
-
-
-def main():
- parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine")
- parser.add_argument("--version", "-v", action="store_true", help="Show version")
- parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)")
- parser.add_argument(
- "--bind", "-b", default="127.0.0.1",
- help="Address to bind the HTTP gateway to (default: 127.0.0.1). "
- "Use 0.0.0.0 to expose to the LAN; note that the web UI has no authentication.",
- )
- args = parser.parse_args()
-
- if args.version:
- print(f"TinyWeb {get_version()}")
- return
-
- bind_host = args.bind
- port = args.port or 8080
- gateway.GATEWAY_PORT = find_available_port(port, host=bind_host)
-
- init_db()
- transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
- transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
- threading.Thread(target=_preload_embeddings, daemon=True).start()
- config_dir = os.environ.get("RNS_CONFIG_DIR")
- ensure_rns_config(config_dir, transport_host, transport_port)
- reticulum = RNS.Reticulum(configdir=config_dir)
- identity = load_or_create_identity()
-
- destination = RNS.Destination(
- identity,
- RNS.Destination.IN,
- RNS.Destination.SINGLE,
- gateway.APP_NAME,
- *gateway.ASPECTS,
- )
-
- destination.register_request_handler(
- "/tinyweb",
- response_generator=rns_request_handler,
- allow=RNS.Destination.ALLOW_ALL,
- )
-
- # Initialize forum plugin if available
- forum = None
- try:
- from tinyweb_forum import ForumPlugin
- from db import get_site_name
- forum = ForumPlugin(DATA_DIR, identity, reticulum, site_name=get_site_name())
- if get_setting("forum_enabled", "0") == "1":
- forum.enable()
- templates_mod.FORUM_ENABLED = True
- handlers_mod.forum_plugin = forum
- print(f"Forum plugin: {'enabled' if forum.is_enabled() else 'available (enable in settings)'}")
- except ImportError:
- print("Forum plugin not installed (pip install tinyweb[forum])")
- except Exception as e:
- print(f"Forum plugin error: {e}")
-
- # Brief delay to ensure all interfaces (especially TCP) are fully ready
- time.sleep(2)
- destination.announce()
- set_setting("dest_hash", destination.hash.hex())
- start_gateway(reticulum, bind_host=bind_host)
-
- print(f"TinyWeb running!")
- if bind_host in ("0.0.0.0", "::"):
- print(f"Open http://localhost:{gateway.GATEWAY_PORT} in your browser")
- print(f"WARNING: listening on {bind_host} — the web UI has no authentication. "
- "Anyone on your network can control this instance.")
- else:
- print(f"Open http://{bind_host}:{gateway.GATEWAY_PORT} in your browser")
- print(f"Destination hash: {RNS.prettyhexrep(destination.hash)} (share this so friends can subscribe)")
-
- while True:
- time.sleep(1)
-
-
-if __name__ == "__main__":
- main()
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent / "src"))
+from tinyweb.app import main
+main()
diff --git a/conftest.py b/conftest.py
index 9a2f26e..4b5c8be 100644
--- a/conftest.py
+++ b/conftest.py
@@ -5,15 +5,11 @@ per-test tempfile, `seeded_db` layers sample rows on top, and `csrf_session`
primes the thread-local CSRF token that handlers read.
"""
import socket
-import sys
-from pathlib import Path
import pytest
-sys.path.insert(0, str(Path(__file__).parent))
-
-import db as db_module
-import handlers as handlers_module
+import tinyweb.db as db_module
+import tinyweb.handlers as handlers_module
@pytest.fixture
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6dfbe36
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,12 @@
+[project]
+name = "tinyweb"
+version = "0.1.0"
+description = "Personal decentralized search engine"
+requires-python = ">=3.10"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.backends._legacy:_Backend"
diff --git a/src/tinyweb/__init__.py b/src/tinyweb/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/tinyweb/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py
new file mode 100644
index 0000000..ca3b6de
--- /dev/null
+++ b/src/tinyweb/app.py
@@ -0,0 +1,312 @@
+import os
+import sys
+import time
+import threading
+import argparse
+import RNS
+from http.server import HTTPServer, ThreadingHTTPServer
+
+from tinyweb.db import init_db, get_setting, set_setting
+from tinyweb.handlers import dispatch_request
+import tinyweb.handlers as handlers_mod
+import tinyweb.templates as templates_mod
+import tinyweb.gateway
+from tinyweb.gateway import GatewayState, GatewayHandler
+
+IDENTITY_FILE = "tinyweb_identity"
+DEFAULT_TRANSPORT_HOST = "rnode.bre.land"
+DEFAULT_TRANSPORT_PORT = 4242
+DATA_DIR = os.environ.get("TINYWEB_DATA_DIR") or os.path.expanduser("~/.tinyweb")
+
+
+def get_transport_config():
+ host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))
+ return host, int(port)
+
+
+def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"):
+ """Find an available port starting from start."""
+ import socket
+ for port in range(start, start + max_attempts):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ s.bind((host, port))
+ return port
+ except OSError:
+ continue
+ return start
+
+
+def get_version():
+ """Get version from git tag or VERSION file."""
+ try:
+ import subprocess
+ tag = subprocess.check_output(
+ ["git", "describe", "--tags", "--abbrev=0"],
+ stderr=subprocess.DEVNULL,
+ text=True
+ ).strip()
+ if tag.startswith("v"):
+ return tag[1:]
+ return tag
+ except Exception:
+ version_file = os.path.join(os.path.dirname(__file__), "VERSION")
+ if os.path.exists(version_file):
+ with open(version_file) as f:
+ return f.read().strip()
+ return "0.0.0"
+
+
+def load_or_create_identity():
+ os.makedirs(DATA_DIR, exist_ok=True)
+ identity_path = os.path.join(DATA_DIR, IDENTITY_FILE)
+ if os.path.isfile(identity_path):
+ current = os.stat(identity_path).st_mode & 0o777
+ if current != 0o600:
+ os.chmod(identity_path, 0o600)
+ return RNS.Identity.from_file(identity_path)
+ identity = RNS.Identity()
+ identity.to_file(identity_path)
+ os.chmod(identity_path, 0o600)
+ return identity
+
+
+# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface.
+# Any other method/path is rejected here — CSRF cannot authenticate mesh callers
+# (the attacker controls both the "cookie" and the "form" side of the check), so
+# gating by whitelist is the only safe option.
+_RNS_ALLOWED = {("GET", "/api/sites")}
+
+
+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": ""}
+ method = data.get("method", "GET")
+ req_path = data.get("path", "/")
+ if (method, req_path) not in _RNS_ALLOWED:
+ return {
+ "status": 403,
+ "content_type": "text/plain; charset=utf-8",
+ "body": "Forbidden: this endpoint is not available over Reticulum.",
+ "headers": {},
+ }
+ return dispatch_request(data)
+
+
+def start_gateway(reticulum, bind_host="127.0.0.1"):
+ GatewayState.reticulum = reticulum
+ GatewayState.local_dispatch = dispatch_request
+ HTTPServer.allow_reuse_address = True
+ server = ThreadingHTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+
+def _config_settings_match(config_file, desired_host, desired_port):
+ """Check if existing config transport and LoRa settings match desired values."""
+ import configparser
+ try:
+ config = configparser.ConfigParser()
+ config.read(config_file)
+ # Check TCP transport
+ tcp_enabled = get_setting("tcp_enabled", "1") == "1"
+ has_tcp = config.has_section("TCP Transport")
+ if tcp_enabled != has_tcp:
+ return False
+ if tcp_enabled and has_tcp:
+ if (config.get("TCP Transport", "target_host") != desired_host or
+ config.get("TCP Transport", "target_port") != str(desired_port)):
+ return False
+ # Check LoRa
+ lora_enabled = get_setting("lora_enabled", "0") == "1"
+ has_lora = config.has_section("RNode LoRa")
+ if lora_enabled != has_lora:
+ return False
+ if lora_enabled and has_lora:
+ if config.get("RNode LoRa", "port", fallback="") != get_setting("lora_port", ""):
+ return False
+ if config.get("RNode LoRa", "frequency", fallback="") != get_setting("lora_frequency", "867200000"):
+ return False
+ return True
+ except Exception:
+ pass
+ return False
+
+
+def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
+ """Generate a default Reticulum config with internet transport if none exists."""
+ if config_dir is None:
+ config_dir = os.path.expanduser("~/.reticulum")
+ config_file = os.path.join(config_dir, "config")
+ if transport_host is None:
+ transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ if transport_port is None:
+ transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
+
+ managed_sentinel = "# managed by tinyweb"
+ if os.path.exists(config_file):
+ try:
+ with open(config_file) as f:
+ existing = f.read()
+ except OSError:
+ existing = ""
+ if managed_sentinel not in existing:
+ # User-authored config — don't clobber it.
+ if not _config_settings_match(config_file, transport_host, transport_port):
+ print(
+ f"Warning: {config_file} was not created by tinyweb; "
+ "leaving it alone. Edit it manually to change transport/LoRa settings."
+ )
+ return
+ if _config_settings_match(config_file, transport_host, transport_port):
+ return
+
+ # Build optional interface blocks
+ tcp_block = ""
+ if get_setting("tcp_enabled", "1") == "1":
+ tcp_block = f"""
+ [[TCP Transport]]
+ type = TCPClientInterface
+ enabled = yes
+ target_host = {transport_host}
+ target_port = {transport_port}
+"""
+
+ lora_block = ""
+ if get_setting("lora_enabled", "0") == "1":
+ lora_port = get_setting("lora_port", "")
+ if lora_port:
+ lora_frequency = get_setting("lora_frequency", "867200000")
+ lora_bandwidth = get_setting("lora_bandwidth", "125000")
+ lora_txpower = get_setting("lora_txpower", "7")
+ lora_sf = get_setting("lora_sf", "8")
+ lora_cr = get_setting("lora_cr", "5")
+ lora_block = f"""
+ [[RNode LoRa]]
+ type = RNodeInterface
+ enabled = yes
+ port = {lora_port}
+ frequency = {lora_frequency}
+ bandwidth = {lora_bandwidth}
+ txpower = {lora_txpower}
+ spreadingfactor = {lora_sf}
+ codingrate = {lora_cr}
+"""
+
+ os.makedirs(config_dir, exist_ok=True)
+ with open(config_file, "w") as f:
+ f.write(f"""{managed_sentinel}
+[reticulum]
+ enable_transport = False
+ share_instance = No
+
+[logging]
+ loglevel = 4
+
+[interfaces]
+ [[Default Interface]]
+ type = AutoInterface
+ enabled = Yes
+{tcp_block}{lora_block}""")
+ print(f"Created Reticulum config at {config_file}")
+
+
+def _preload_embeddings():
+ """Pre-load the embedding model and build the HNSW index in background."""
+ if get_setting("semantic_search", "0") != "1":
+ print("Semantic search disabled.")
+ return
+ try:
+ from tinyweb.embeddings import _get_session, _get_reranker, build_index
+ _get_session()
+ build_index()
+ if get_setting("use_reranker", "0") == "1":
+ _get_reranker()
+ print("Semantic search ready (with reranker).")
+ else:
+ print("Semantic search ready.")
+ except Exception as e:
+ print(f"Semantic search unavailable: {e}")
+
+
+def main():
+ parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine")
+ parser.add_argument("--version", "-v", action="store_true", help="Show version")
+ parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)")
+ parser.add_argument(
+ "--bind", "-b", default="127.0.0.1",
+ help="Address to bind the HTTP gateway to (default: 127.0.0.1). "
+ "Use 0.0.0.0 to expose to the LAN; note that the web UI has no authentication.",
+ )
+ args = parser.parse_args()
+
+ if args.version:
+ print(f"TinyWeb {get_version()}")
+ return
+
+ bind_host = args.bind
+ port = args.port or 8080
+ gateway.GATEWAY_PORT = find_available_port(port, host=bind_host)
+
+ init_db()
+ transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
+ transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
+ threading.Thread(target=_preload_embeddings, daemon=True).start()
+ config_dir = os.environ.get("RNS_CONFIG_DIR")
+ ensure_rns_config(config_dir, transport_host, transport_port)
+ reticulum = RNS.Reticulum(configdir=config_dir)
+ identity = load_or_create_identity()
+
+ destination = RNS.Destination(
+ identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ gateway.APP_NAME,
+ *gateway.ASPECTS,
+ )
+
+ destination.register_request_handler(
+ "/tinyweb",
+ response_generator=rns_request_handler,
+ allow=RNS.Destination.ALLOW_ALL,
+ )
+
+ # Initialize forum plugin if available
+ forum = None
+ try:
+ from tinyweb_forum import ForumPlugin
+ from tinyweb.db import get_site_name
+ forum = ForumPlugin(DATA_DIR, identity, reticulum, site_name=get_site_name())
+ if get_setting("forum_enabled", "0") == "1":
+ forum.enable()
+ templates_mod.FORUM_ENABLED = True
+ handlers_mod.forum_plugin = forum
+ print(f"Forum plugin: {'enabled' if forum.is_enabled() else 'available (enable in settings)'}")
+ except ImportError:
+ print("Forum plugin not installed (pip install tinyweb[forum])")
+ except Exception as e:
+ print(f"Forum plugin error: {e}")
+
+ # Brief delay to ensure all interfaces (especially TCP) are fully ready
+ time.sleep(2)
+ destination.announce()
+ set_setting("dest_hash", destination.hash.hex())
+ start_gateway(reticulum, bind_host=bind_host)
+
+ print(f"TinyWeb running!")
+ if bind_host in ("0.0.0.0", "::"):
+ print(f"Open http://localhost:{gateway.GATEWAY_PORT} in your browser")
+ print(f"WARNING: listening on {bind_host} — the web UI has no authentication. "
+ "Anyone on your network can control this instance.")
+ else:
+ print(f"Open http://{bind_host}:{gateway.GATEWAY_PORT} in your browser")
+ print(f"Destination hash: {RNS.prettyhexrep(destination.hash)} (share this so friends can subscribe)")
+
+ while True:
+ time.sleep(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/db.py b/src/tinyweb/db.py
similarity index 99%
rename from db.py
rename to src/tinyweb/db.py
index 97378d9..d7f269b 100644
--- a/db.py
+++ b/src/tinyweb/db.py
@@ -440,7 +440,7 @@ def index_url(url, note="", reticulum_dest=""):
db.commit()
if get_setting("semantic_search", "0") == "1":
try:
- from embeddings import store_embeddings
+ from tinyweb.embeddings import store_embeddings
store_embeddings(page_id, title, body, db)
except Exception:
pass # embedding generation is best-effort
diff --git a/embeddings.py b/src/tinyweb/embeddings.py
similarity index 98%
rename from embeddings.py
rename to src/tinyweb/embeddings.py
index 03f6f13..2aecee4 100644
--- a/embeddings.py
+++ b/src/tinyweb/embeddings.py
@@ -246,7 +246,7 @@ def embed(texts, is_query=False):
def _maybe_compress(embeddings):
"""Compress embeddings to float16 if compression is enabled."""
try:
- from db import get_setting
+ from tinyweb.db import get_setting
if get_setting("compress_embeddings", "0") == "1":
return embeddings.astype(np.float16)
except Exception:
@@ -279,7 +279,7 @@ def build_index(db=None):
import hnswlib
global _hnsw_index, _hnsw_ids
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
@@ -428,7 +428,7 @@ def semantic_search(query_text, limit=100, db=None):
scores = [1.0 - float(d) for d in distances[0]]
# Fetch chunk details from DB
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
@@ -501,7 +501,7 @@ def hybrid_search(query_text, bm25_ranked_ids, limit=10, db=None, use_reranker=F
rerank_ids = all_ids[:20]
tail_ids = all_ids[20:30]
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
@@ -553,7 +553,7 @@ def hybrid_search(query_text, bm25_ranked_ids, limit=10, db=None, use_reranker=F
def reindex_all(db=None, progress_callback=None):
"""Re-embed all pages and regenerate all summaries. Rebuilds HNSW index."""
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
own_db = db is None
if own_db:
db = get_db()
diff --git a/gateway.py b/src/tinyweb/gateway.py
similarity index 99%
rename from gateway.py
rename to src/tinyweb/gateway.py
index fa4b076..5b292ff 100644
--- a/gateway.py
+++ b/src/tinyweb/gateway.py
@@ -201,7 +201,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
def main():
if len(sys.argv) < 2:
- print(f"Usage: python gateway.py ")
+ print(f"Usage: python -m tinyweb.gateway ")
print(f" The destination hash is printed by app.py on startup.")
sys.exit(1)
diff --git a/handlers/__init__.py b/src/tinyweb/handlers/__init__.py
similarity index 97%
rename from handlers/__init__.py
rename to src/tinyweb/handlers/__init__.py
index 228508e..524ae8d 100644
--- a/handlers/__init__.py
+++ b/src/tinyweb/handlers/__init__.py
@@ -3,10 +3,10 @@ import secrets
import threading
from urllib.parse import unquote
-from db import get_db, return_db, set_setting
-import templates as templates_mod
-from templates import esc, wrap_page
-from rns_client import fetch_remote_sites
+from tinyweb.db import get_db, return_db, set_setting
+import tinyweb.templates as templates_mod
+from tinyweb.templates import esc, wrap_page
+from tinyweb.rns_client import fetch_remote_sites
from ._helpers import (
_request_local, _get_csrf_token, _csrf_field, _check_csrf,
@@ -132,7 +132,7 @@ def _dispatch_inner(data):
_set_flash("Template reset to default.")
return _redirect("/style")
elif path == "/style/vacuum":
- from db import vacuum_db
+ from tinyweb.db import vacuum_db
vacuum_db()
_set_flash("Database vacuumed.")
return _redirect("/style")
diff --git a/handlers/_helpers.py b/src/tinyweb/handlers/_helpers.py
similarity index 97%
rename from handlers/_helpers.py
rename to src/tinyweb/handlers/_helpers.py
index 2611ce7..c7e8d83 100644
--- a/handlers/_helpers.py
+++ b/src/tinyweb/handlers/_helpers.py
@@ -3,8 +3,8 @@ import re
import secrets
import threading
-from db import get_db, return_db, get_setting, set_setting
-from templates import wrap_page
+from tinyweb.db import get_db, return_db, get_setting, set_setting
+from tinyweb.templates import wrap_page
_request_local = threading.local()
diff --git a/handlers/customize.py b/src/tinyweb/handlers/customize.py
similarity index 98%
rename from handlers/customize.py
rename to src/tinyweb/handlers/customize.py
index 6b3786d..959476d 100644
--- a/handlers/customize.py
+++ b/src/tinyweb/handlers/customize.py
@@ -1,6 +1,6 @@
-from db import get_db, return_db, get_setting, set_setting, get_site_name
-import templates as templates_mod
-from templates import esc, DEFAULT_TEMPLATE
+from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name
+import tinyweb.templates as templates_mod
+from tinyweb.templates import esc, DEFAULT_TEMPLATE
from ._helpers import _respond, _redirect, _json_response, _csrf_field, _get_bookmark_token, _request_local
from .subscriptions import _count_shared_pages
@@ -48,7 +48,7 @@ def handle_style_form(msg="", gateway_host="", scheme="http"):
lora_sf = get_setting("lora_sf", "8")
lora_cr = get_setting("lora_cr", "5")
csrf = _csrf_field()
- from handlers import forum_plugin as _fp
+ from tinyweb.handlers import forum_plugin as _fp
if _fp is not None:
forum_body = (
f""
@@ -223,7 +223,7 @@ def handle_style_submit(body, gateway_host="", scheme="http"):
forum_enabled = "1" if body.get("forum_enabled") else "0"
current_forum = get_setting("forum_enabled", "0")
if forum_enabled != current_forum:
- from handlers import forum_plugin
+ from tinyweb.handlers import forum_plugin
if forum_enabled == "1" and forum_plugin is None:
_set_flash("Forum plugin not installed. Run: pip install tinyweb-forum")
return _redirect("/style")
@@ -288,7 +288,7 @@ def handle_field_save(body):
if not key:
return _json_response({"status": "error", "message": "No key provided."}, 400)
if key == "forum_enabled":
- from handlers import forum_plugin
+ from tinyweb.handlers import forum_plugin
if value == "1" and forum_plugin is None:
return _json_response({"status": "error", "message": "Forum plugin not installed."}, 400)
if value == "1":
diff --git a/handlers/data.py b/src/tinyweb/handlers/data.py
similarity index 95%
rename from handlers/data.py
rename to src/tinyweb/handlers/data.py
index d3a714f..c16a65f 100644
--- a/handlers/data.py
+++ b/src/tinyweb/handlers/data.py
@@ -1,8 +1,8 @@
import json
import threading
-from db import get_db, return_db, get_setting, set_setting, index_url
-from templates import esc
+from tinyweb.db import get_db, return_db, get_setting, set_setting, index_url
+from tinyweb.templates import esc
from ._helpers import _respond, _json_response, _redirect, _csrf_field
MAX_EXPORT = 10000
@@ -111,7 +111,7 @@ def handle_reindex_submit(body):
def _run():
try:
- from embeddings import reindex_all
+ from tinyweb.embeddings import reindex_all
def progress(current, total):
set_setting("reindex_progress", f"{current}/{total}")
reindex_all(progress_callback=progress)
diff --git a/handlers/pages.py b/src/tinyweb/handlers/pages.py
similarity index 98%
rename from handlers/pages.py
rename to src/tinyweb/handlers/pages.py
index 0799251..4eb2a12 100644
--- a/handlers/pages.py
+++ b/src/tinyweb/handlers/pages.py
@@ -3,8 +3,8 @@ import json
import secrets
from urllib.parse import unquote
-from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
-from templates import esc
+from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
+from tinyweb.templates import esc
from ._helpers import (
_csrf_field, _respond, _redirect, _error,
_paginate, _page_nav, _get_page_tags, _set_page_tags, _cleanup_orphaned_tags,
@@ -137,7 +137,7 @@ def handle_add_manual_submit(body):
if get_setting("semantic_search", "0") == "1":
try:
- from embeddings import store_embeddings
+ from tinyweb.embeddings import store_embeddings
store_embeddings(page_id, manual_title, manual_desc, db)
db.commit()
except Exception as e:
diff --git a/handlers/search.py b/src/tinyweb/handlers/search.py
similarity index 97%
rename from handlers/search.py
rename to src/tinyweb/handlers/search.py
index d9ee7c1..951b4d4 100644
--- a/handlers/search.py
+++ b/src/tinyweb/handlers/search.py
@@ -1,5 +1,5 @@
-from db import get_db, return_db, get_setting, get_site_name, clean_url
-from templates import esc
+from tinyweb.db import get_db, return_db, get_setting, get_site_name, clean_url
+from tinyweb.templates import esc
from ._helpers import _sanitize_fts_query, _paginate, _get_page_tags, _respond, _page_nav, PER_PAGE
@@ -31,7 +31,7 @@ def handle_search(query):
chunk_snippets = {}
if get_setting("semantic_search", "0") == "1":
try:
- from embeddings import hybrid_search
+ from tinyweb.embeddings import hybrid_search
use_reranker = get_setting("use_reranker", "1") == "1"
fused = hybrid_search(q, bm25_ids, limit=100, db=db, use_reranker=use_reranker)
fused_ids = [pid for pid, _ in fused]
diff --git a/handlers/subscriptions.py b/src/tinyweb/handlers/subscriptions.py
similarity index 98%
rename from handlers/subscriptions.py
rename to src/tinyweb/handlers/subscriptions.py
index 97f20b2..0113d07 100644
--- a/handlers/subscriptions.py
+++ b/src/tinyweb/handlers/subscriptions.py
@@ -1,9 +1,9 @@
import threading
from datetime import datetime
-from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
-from templates import esc
-from rns_client import fetch_remote_sites
+from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
+from tinyweb.templates import esc
+from tinyweb.rns_client import fetch_remote_sites
from ._helpers import (
_get_page_tags, _respond, _redirect, _json_response, _error,
_csrf_field,
@@ -389,7 +389,7 @@ def _sync_subscription(sub_id):
)
if get_setting("semantic_search", "0") == "1":
try:
- from embeddings import store_remote_embeddings
+ from tinyweb.embeddings import store_remote_embeddings
rp_id = db.execute(
"SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
(sub_id, s["url"]),
diff --git a/handlers/tags.py b/src/tinyweb/handlers/tags.py
similarity index 96%
rename from handlers/tags.py
rename to src/tinyweb/handlers/tags.py
index f0e927d..46189d6 100644
--- a/handlers/tags.py
+++ b/src/tinyweb/handlers/tags.py
@@ -1,5 +1,5 @@
-from db import get_db, return_db
-from templates import esc
+from tinyweb.db import get_db, return_db
+from tinyweb.templates import esc
from ._helpers import _respond, _paginate, _page_nav, _get_page_tags, BROWSE_PER_PAGE
diff --git a/rns_client.py b/src/tinyweb/rns_client.py
similarity index 100%
rename from rns_client.py
rename to src/tinyweb/rns_client.py
diff --git a/templates.py b/src/tinyweb/templates.py
similarity index 97%
rename from templates.py
rename to src/tinyweb/templates.py
index 27a1a6b..8208211 100644
--- a/templates.py
+++ b/src/tinyweb/templates.py
@@ -1,5 +1,5 @@
import html
-from db import get_setting
+from tinyweb.db import get_setting
FORUM_ENABLED = False
diff --git a/tests/test_csrf.py b/tests/test_csrf.py
index 43b4487..82f2584 100644
--- a/tests/test_csrf.py
+++ b/tests/test_csrf.py
@@ -4,8 +4,8 @@ Every POST handler calls this to verify the submitted _csrf field matches
the token stored in the thread-local (which is seeded from the cookie by
`dispatch_request`). Missing or mismatched tokens must fail closed.
"""
-import handlers as handlers_module
-from handlers import _check_csrf, _csrf_field, _get_csrf_token
+import tinyweb.handlers as handlers_module
+from tinyweb.handlers import _check_csrf, _csrf_field, _get_csrf_token
def _set_token(token):
diff --git a/tests/test_db_index_url.py b/tests/test_db_index_url.py
index 50f73ce..aac60a5 100644
--- a/tests/test_db_index_url.py
+++ b/tests/test_db_index_url.py
@@ -6,8 +6,8 @@ in sync via triggers, and the connection pool returning clean connections.
from unittest.mock import patch
from conftest import patch_dns_ok
-import db as db_module
-from db import get_db, return_db, index_url
+import tinyweb.db as db_module
+from tinyweb.db import get_db, return_db, index_url
def _mock_fetch_page(title="Test Page", body="test body text", links=None, meta=""):
diff --git a/tests/test_db_schema.py b/tests/test_db_schema.py
index 5a4f77c..4bc6691 100644
--- a/tests/test_db_schema.py
+++ b/tests/test_db_schema.py
@@ -3,7 +3,7 @@
`init_db` is called unconditionally on startup, so it must be idempotent
and create every table/trigger the rest of the app expects.
"""
-from db import get_db, return_db, init_db, get_setting, set_setting, get_site_name
+from tinyweb.db import get_db, return_db, init_db, get_setting, set_setting, get_site_name
EXPECTED_TABLES = {
diff --git a/tests/test_fts_sanitizer.py b/tests/test_fts_sanitizer.py
index ad061da..08afb6c 100644
--- a/tests/test_fts_sanitizer.py
+++ b/tests/test_fts_sanitizer.py
@@ -6,7 +6,7 @@ could escape the quoting. These tests keep that regression dead.
"""
import pytest
-from handlers import _sanitize_fts_query
+from tinyweb.handlers import _sanitize_fts_query
def test_empty_query_returns_no_match_token():
diff --git a/tests/test_gateway_limits.py b/tests/test_gateway_limits.py
index 6033c3a..a772968 100644
--- a/tests/test_gateway_limits.py
+++ b/tests/test_gateway_limits.py
@@ -8,8 +8,8 @@ import io
import pytest
-import app as app_module
-from gateway import GatewayHandler, MAX_BODY_SIZE
+from tinyweb import app as app_module
+from tinyweb.gateway import GatewayHandler, MAX_BODY_SIZE
class FakeHeaders:
@@ -72,7 +72,7 @@ def test_post_at_size_cap_accepted():
rfile=io.BytesIO(b""),
)
# Stub out local_dispatch so _forward doesn't try the network path.
- from gateway import GatewayState
+ from tinyweb.gateway import GatewayState
original = GatewayState.local_dispatch
GatewayState.local_dispatch = lambda data: {
"status": 404, "content_type": "text/plain", "body": "nope",
diff --git a/tests/test_handlers_pages.py b/tests/test_handlers_pages.py
index ab4704c..bc80ad0 100644
--- a/tests/test_handlers_pages.py
+++ b/tests/test_handlers_pages.py
@@ -4,8 +4,8 @@ The bulk-delete confirmation flow is a data-loss guard added in commit
8dffd8c — a stray POST without `confirmed=1` must render the confirmation
page instead of actually deleting.
"""
-from db import get_db, return_db
-from handlers import (
+from tinyweb.db import get_db, return_db
+from tinyweb.handlers import (
handle_bulk_action,
handle_edit_form,
handle_edit_submit,
diff --git a/tests/test_handlers_search.py b/tests/test_handlers_search.py
index f7d2f9e..3f4fb14 100644
--- a/tests/test_handlers_search.py
+++ b/tests/test_handlers_search.py
@@ -1,5 +1,5 @@
"""Tests for `handle_search` — the home page + primary user flow."""
-from handlers import handle_search
+from tinyweb.handlers import handle_search
def test_empty_index_empty_query_shows_welcome(temp_db, csrf_session):
diff --git a/tests/test_handlers_subs.py b/tests/test_handlers_subs.py
index 93ee97d..c24ea7b 100644
--- a/tests/test_handlers_subs.py
+++ b/tests/test_handlers_subs.py
@@ -6,9 +6,9 @@ available and falls back to a live fetch otherwise.
"""
from unittest.mock import patch
-import handlers as handlers_module
-from db import get_db, return_db
-from handlers import handle_subscription_add, handle_subscription_browse
+import tinyweb.handlers as handlers_module
+from tinyweb.db import get_db, return_db
+from tinyweb.handlers import handle_subscription_add, handle_subscription_browse
VALID_HASH = "a" * 32
diff --git a/tests/test_handlers_tags.py b/tests/test_handlers_tags.py
index 7ec8f05..3afdad7 100644
--- a/tests/test_handlers_tags.py
+++ b/tests/test_handlers_tags.py
@@ -4,8 +4,8 @@ Tags are stored via a join table, so orphaned rows in `tags` can accumulate
if `_cleanup_orphaned_tags` isn't called after deletion/retagging. Tag
counts shown in the UI rely on this being right.
"""
-from db import get_db, return_db
-from handlers import (
+from tinyweb.db import get_db, return_db
+from tinyweb.handlers import (
_cleanup_orphaned_tags,
_get_page_tags,
_set_page_tags,
diff --git a/tests/test_link_extraction.py b/tests/test_link_extraction.py
index 2d8c741..0baba34 100644
--- a/tests/test_link_extraction.py
+++ b/tests/test_link_extraction.py
@@ -7,7 +7,7 @@ skip Wikipedia special pages, resolve relatives via urljoin.
from unittest.mock import patch
from conftest import patch_dns_ok
-import db as db_module
+import tinyweb.db as db_module
class FakeResponse:
diff --git a/tests/test_pagination.py b/tests/test_pagination.py
index 05077e0..6b6f727 100644
--- a/tests/test_pagination.py
+++ b/tests/test_pagination.py
@@ -1,5 +1,5 @@
"""Tests for `_paginate` and `_page_nav`."""
-from handlers import _paginate, _page_nav, PER_PAGE
+from tinyweb.handlers import _paginate, _page_nav, PER_PAGE
def test_paginate_default_is_one():
diff --git a/tests/test_regressions.py b/tests/test_regressions.py
index f8a5df7..eeab752 100644
--- a/tests/test_regressions.py
+++ b/tests/test_regressions.py
@@ -14,12 +14,12 @@ from unittest.mock import patch
import pytest
-import app as app_module
-import db as db_module
-import handlers as handlers_module
+from tinyweb import app as app_module
+import tinyweb.db as db_module
+import tinyweb.handlers as handlers_module
from conftest import patch_dns_fail, patch_dns_ok
-from db import clean_url
-from handlers import _sanitize_fts_query, handle_bulk_action
+from tinyweb.db import clean_url
+from tinyweb.handlers import _sanitize_fts_query, handle_bulk_action
def test_6ffd38d_clean_url_preserves_www_when_bare_domain_fails(monkeypatch):
@@ -47,7 +47,7 @@ def test_1bc695f_fts_sanitizer_drops_operator_words(op):
def test_1bc695f_gateway_rejects_oversize_body():
"""1bc695f: 16 MiB body-size cap prevents memory-exhaustion DoS."""
from tests.test_gateway_limits import FakeGatewayHandler
- from gateway import MAX_BODY_SIZE
+ from tinyweb.gateway import MAX_BODY_SIZE
h = FakeGatewayHandler(
path="/add", method="POST",
headers={"Content-Length": str(MAX_BODY_SIZE + 1)},
@@ -70,7 +70,7 @@ def test_1bc695f_mesh_rejects_non_whitelisted_paths():
def test_1bc695f_pool_returns_clean_connection(temp_db, monkeypatch):
"""1bc695f: uncommitted transactions on a pooled connection used to leak
into the next consumer."""
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
db = get_db()
db.execute(
"INSERT INTO pages (url, title, body) VALUES (?, ?, ?)",
@@ -88,7 +88,7 @@ def test_1bc695f_pool_returns_clean_connection(temp_db, monkeypatch):
def test_8dffd8c_bulk_delete_requires_confirmation(seeded_db, csrf_session):
"""8dffd8c: bulk delete without confirmed=1 must render a confirm page
instead of deleting — the JS confirm on /pages is a first-line filter only."""
- from db import get_db, return_db
+ from tinyweb.db import get_db, return_db
db = get_db()
try:
pid = db.execute("SELECT id FROM pages LIMIT 1").fetchone()["id"]
diff --git a/tests/test_sharing_logic.py b/tests/test_sharing_logic.py
index c9c06d4..36dca46 100644
--- a/tests/test_sharing_logic.py
+++ b/tests/test_sharing_logic.py
@@ -6,7 +6,7 @@ hiding pages the user meant to share — both are worth a regression net.
"""
import pytest
-from handlers import _page_is_shared
+from tinyweb.handlers import _page_is_shared
@pytest.mark.parametrize("mode", ["exclude_private", "require_public"])
diff --git a/tests/test_ssrf.py b/tests/test_ssrf.py
index 807f9bd..31eb132 100644
--- a/tests/test_ssrf.py
+++ b/tests/test_ssrf.py
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
-from db import _validate_url_target
+from tinyweb.db import _validate_url_target
def _mock_getaddrinfo(address):
diff --git a/tests/test_url_cleanup.py b/tests/test_url_cleanup.py
index 1eef72b..8ade28b 100644
--- a/tests/test_url_cleanup.py
+++ b/tests/test_url_cleanup.py
@@ -6,7 +6,7 @@ this function can silently cause duplicate rows or mask legitimate saves.
import pytest
from conftest import patch_dns_ok, patch_dns_fail
-from db import clean_url, TRACKING_PARAMS
+from tinyweb.db import clean_url, TRACKING_PARAMS
def test_strips_fragment(monkeypatch):
From f3bfe50abc96873784e752e2c5342d9a8451da44 Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 17 Jun 2026 05:16:34 +0000
Subject: [PATCH 179/194] readme: update project structure to reflect src
layout
---
README.md | 35 ++++++++++++++++++++---------------
1 file changed, 20 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index c092eda..b38ae0e 100644
--- a/README.md
+++ b/README.md
@@ -215,21 +215,26 @@ For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyw
## Project structure
```
-app.py — Entry point: boots Reticulum, starts HTTP gateway
-gateway.py — HTTP-to-RNS bridge (local or remote dispatch)
-handlers/ — Route dispatcher and request handlers
- __init__.py — Dispatch logic + re-exports
- _helpers.py — CSRF, FTS sanitizer, pagination, response builders
- search.py — Search (BM25, hybrid, trusted/remote results)
- pages.py — Add/edit/delete/bulk/bookmark handlers
- subscriptions.py — Sync, sharing, API, subscription CRUD
- customize.py — Settings form, about page
- tags.py — Tag list and browse
- data.py — Export, import, semantic reindex
-db.py — SQLite database, FTS5, URL fetching, SSRF protection
-templates.py — HTML template rendering and escaping
-rns_client.py — Reticulum client for fetching remote site lists
-themes/ — Saved HTML templates (e.g. kodama.html)
+app.py — Entry point (shim, imports from tinyweb.app)
+pyproject.toml — Package configuration (src layout)
+src/tinyweb/
+ __init__.py — Package marker
+ app.py — Boots Reticulum, starts HTTP gateway
+ db.py — SQLite database, FTS5, URL fetching, SSRF protection
+ gateway.py — HTTP-to-RNS bridge (local or remote dispatch)
+ templates.py — HTML template rendering and escaping
+ embeddings.py — Semantic search: ONNX, HNSW, reranking
+ rns_client.py — Reticulum client for fetching remote site lists
+ handlers/
+ __init__.py — Dispatch logic + re-exports
+ _helpers.py — CSRF, FTS sanitizer, pagination, response builders
+ search.py — Search (BM25, hybrid, trusted/remote results)
+ pages.py — Add/edit/delete/bulk/bookmark handlers
+ subscriptions.py — Sync, sharing, API, subscription CRUD
+ customize.py — Settings form, about page
+ tags.py — Tag list and browse
+ data.py — Export, import, semantic reindex
+themes/ — Saved HTML templates (e.g. default.html, junimo.html)
```
## Security
From b07ca37663545ace1dc4730d4a5abdf1e7dc0d9b Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 17 Jun 2026 05:16:34 +0000
Subject: [PATCH 180/194] readme: update project structure to reflect src
layout
---
README.md | 35 ++++++++++++++++++++---------------
1 file changed, 20 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index c092eda..b38ae0e 100644
--- a/README.md
+++ b/README.md
@@ -215,21 +215,26 @@ For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyw
## Project structure
```
-app.py — Entry point: boots Reticulum, starts HTTP gateway
-gateway.py — HTTP-to-RNS bridge (local or remote dispatch)
-handlers/ — Route dispatcher and request handlers
- __init__.py — Dispatch logic + re-exports
- _helpers.py — CSRF, FTS sanitizer, pagination, response builders
- search.py — Search (BM25, hybrid, trusted/remote results)
- pages.py — Add/edit/delete/bulk/bookmark handlers
- subscriptions.py — Sync, sharing, API, subscription CRUD
- customize.py — Settings form, about page
- tags.py — Tag list and browse
- data.py — Export, import, semantic reindex
-db.py — SQLite database, FTS5, URL fetching, SSRF protection
-templates.py — HTML template rendering and escaping
-rns_client.py — Reticulum client for fetching remote site lists
-themes/ — Saved HTML templates (e.g. kodama.html)
+app.py — Entry point (shim, imports from tinyweb.app)
+pyproject.toml — Package configuration (src layout)
+src/tinyweb/
+ __init__.py — Package marker
+ app.py — Boots Reticulum, starts HTTP gateway
+ db.py — SQLite database, FTS5, URL fetching, SSRF protection
+ gateway.py — HTTP-to-RNS bridge (local or remote dispatch)
+ templates.py — HTML template rendering and escaping
+ embeddings.py — Semantic search: ONNX, HNSW, reranking
+ rns_client.py — Reticulum client for fetching remote site lists
+ handlers/
+ __init__.py — Dispatch logic + re-exports
+ _helpers.py — CSRF, FTS sanitizer, pagination, response builders
+ search.py — Search (BM25, hybrid, trusted/remote results)
+ pages.py — Add/edit/delete/bulk/bookmark handlers
+ subscriptions.py — Sync, sharing, API, subscription CRUD
+ customize.py — Settings form, about page
+ tags.py — Tag list and browse
+ data.py — Export, import, semantic reindex
+themes/ — Saved HTML templates (e.g. default.html, junimo.html)
```
## Security
From 34f6eeef3f848db99d392d65e1ac1e688f396c1f Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 17 Jun 2026 20:15:50 +0000
Subject: [PATCH 181/194] forum trust circle: trust-gated content exchange via
subscription graph
---
README.md | 2 ++
src/tinyweb/db.py | 6 ++++++
2 files changed, 8 insertions(+)
diff --git a/README.md b/README.md
index b38ae0e..c52f06e 100644
--- a/README.md
+++ b/README.md
@@ -185,6 +185,7 @@ This connects over Reticulum and serves the remote instance at `http://localhost
- Desktop-oriented
- JSON-only import
- Forum threads prune after 30 days by default
+- Forum discovery is through the subscription graph rather than topic-based blooms
- Best-effort maintenance
## Forum plugin
@@ -209,6 +210,7 @@ Enable it on the `/style` page under "Forum". A "Forum" link will appear in the
- Auto-discovery can be disabled in the moderation page
- Threads are auto-pruned after 30 days (configurable, or set to 0 to keep everything)
- Moderation is local: block authors, mute threads, keyword filters, and gossip block lists with peers (auto-block after 3 peer reports)
+- **Trust circle** — Content is gated by a trust graph derived from your TinyWeb subscriptions. Each subscribed peer's forum content (and their transitive trust network) enters your view automatically. Blocking a peer cascades: their downstream trust network is removed from your view.
For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyweb/tinyweb-forum).
diff --git a/src/tinyweb/db.py b/src/tinyweb/db.py
index d7f269b..0e5cea2 100644
--- a/src/tinyweb/db.py
+++ b/src/tinyweb/db.py
@@ -270,6 +270,12 @@ def init_db():
db.execute("ALTER TABLE pages ADD COLUMN reticulum_dest TEXT DEFAULT ''")
db.commit()
+ # Migrate subscriptions: add forum_enabled column
+ sub_cols = [row[1] for row in db.execute("PRAGMA table_info(subscriptions)").fetchall()]
+ if "forum_enabled" not in sub_cols:
+ db.execute("ALTER TABLE subscriptions ADD COLUMN forum_enabled INTEGER DEFAULT 0")
+ db.commit()
+
# Chunks table for semantic search embeddings
db.execute(
"CREATE TABLE IF NOT EXISTS chunks ("
From f418ce7c7540367e10d35e6abaf66a96e8946bd6 Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 17 Jun 2026 20:15:50 +0000
Subject: [PATCH 182/194] forum trust circle: trust-gated content exchange via
subscription graph
---
README.md | 2 ++
src/tinyweb/db.py | 6 ++++++
2 files changed, 8 insertions(+)
diff --git a/README.md b/README.md
index b38ae0e..c52f06e 100644
--- a/README.md
+++ b/README.md
@@ -185,6 +185,7 @@ This connects over Reticulum and serves the remote instance at `http://localhost
- Desktop-oriented
- JSON-only import
- Forum threads prune after 30 days by default
+- Forum discovery is through the subscription graph rather than topic-based blooms
- Best-effort maintenance
## Forum plugin
@@ -209,6 +210,7 @@ Enable it on the `/style` page under "Forum". A "Forum" link will appear in the
- Auto-discovery can be disabled in the moderation page
- Threads are auto-pruned after 30 days (configurable, or set to 0 to keep everything)
- Moderation is local: block authors, mute threads, keyword filters, and gossip block lists with peers (auto-block after 3 peer reports)
+- **Trust circle** — Content is gated by a trust graph derived from your TinyWeb subscriptions. Each subscribed peer's forum content (and their transitive trust network) enters your view automatically. Blocking a peer cascades: their downstream trust network is removed from your view.
For full feature docs, see the [tinyweb-forum README](https://codeberg.org/tinyweb/tinyweb-forum).
diff --git a/src/tinyweb/db.py b/src/tinyweb/db.py
index d7f269b..0e5cea2 100644
--- a/src/tinyweb/db.py
+++ b/src/tinyweb/db.py
@@ -270,6 +270,12 @@ def init_db():
db.execute("ALTER TABLE pages ADD COLUMN reticulum_dest TEXT DEFAULT ''")
db.commit()
+ # Migrate subscriptions: add forum_enabled column
+ sub_cols = [row[1] for row in db.execute("PRAGMA table_info(subscriptions)").fetchall()]
+ if "forum_enabled" not in sub_cols:
+ db.execute("ALTER TABLE subscriptions ADD COLUMN forum_enabled INTEGER DEFAULT 0")
+ db.commit()
+
# Chunks table for semantic search embeddings
db.execute(
"CREATE TABLE IF NOT EXISTS chunks ("
From 8ae2dc53cd3a9b4dc01a7f5d9cd52827a0633675 Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 17 Jun 2026 20:16:06 +0000
Subject: [PATCH 183/194] fix: app.py gateway import alias (broken since src
layout migration)
---
src/tinyweb/app.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py
index ca3b6de..b63ed3c 100644
--- a/src/tinyweb/app.py
+++ b/src/tinyweb/app.py
@@ -10,7 +10,7 @@ from tinyweb.db import init_db, get_setting, set_setting
from tinyweb.handlers import dispatch_request
import tinyweb.handlers as handlers_mod
import tinyweb.templates as templates_mod
-import tinyweb.gateway
+import tinyweb.gateway as gateway
from tinyweb.gateway import GatewayState, GatewayHandler
IDENTITY_FILE = "tinyweb_identity"
From 31c53b9a8ef7cca0885ec170974f4897c6a4bb4b Mon Sep 17 00:00:00 2001
From: blankie
Date: Wed, 17 Jun 2026 20:16:06 +0000
Subject: [PATCH 184/194] fix: app.py gateway import alias (broken since src
layout migration)
---
src/tinyweb/app.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py
index ca3b6de..b63ed3c 100644
--- a/src/tinyweb/app.py
+++ b/src/tinyweb/app.py
@@ -10,7 +10,7 @@ from tinyweb.db import init_db, get_setting, set_setting
from tinyweb.handlers import dispatch_request
import tinyweb.handlers as handlers_mod
import tinyweb.templates as templates_mod
-import tinyweb.gateway
+import tinyweb.gateway as gateway
from tinyweb.gateway import GatewayState, GatewayHandler
IDENTITY_FILE = "tinyweb_identity"
From 8f46878151b1b20e02d85632b744daedfe4eee30 Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 18 Jun 2026 17:15:15 +0000
Subject: [PATCH 185/194] rns browser, standalone site server, unified add form
- /rns// proxies pages over RNS with link rewriting
- fetch_remote_page() in rns_client.py for generic RNS page fetching
- mesh_sites table for persisting saved hashes
- standalone site_server.py with its own RNS identity
- tinyweb-site/index.html: SPA with RSS-aware nav (/rns// prefix)
- unified /add form: single input accepts URL or 32-char RNS hash
- RNS add indexes into pages table (fetch root page, extract title/desc)
- rns: URLs displayed in browse/search, linked to /rns//
- expand RNS whitelist: allow GET /, /about, /pages, /tags, /share
---
src/tinyweb/app.py | 23 +++--
src/tinyweb/db.py | 7 ++
src/tinyweb/handlers/__init__.py | 14 ++-
src/tinyweb/handlers/pages.py | 60 +++++++-----
src/tinyweb/handlers/rns.py | 163 +++++++++++++++++++++++++++++++
src/tinyweb/handlers/search.py | 12 ++-
src/tinyweb/rns_client.py | 45 +++++----
7 files changed, 273 insertions(+), 51 deletions(-)
create mode 100644 src/tinyweb/handlers/rns.py
diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py
index b63ed3c..10b756f 100644
--- a/src/tinyweb/app.py
+++ b/src/tinyweb/app.py
@@ -73,11 +73,22 @@ def load_or_create_identity():
return identity
-# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface.
-# Any other method/path is rejected here — CSRF cannot authenticate mesh callers
-# (the attacker controls both the "cookie" and the "form" side of the check), so
-# gating by whitelist is the only safe option.
-_RNS_ALLOWED = {("GET", "/api/sites")}
+# Remote peers on the Reticulum mesh can reach read-only public pages.
+# Only GET is allowed; POST is blocked because CSRF cannot authenticate
+# mesh callers (the attacker controls both the "cookie" and the "form" side).
+_RNS_ALLOWED_GET = {
+ "/", "/about", "/api/sites", "/share/preview",
+}
+
+_RNS_ALLOWED_PREFIXES = ("/pages", "/tags", "/api/sites", "/rns")
+
+
+def _rns_is_allowed(method, path):
+ if method != "GET":
+ return False
+ if path in _RNS_ALLOWED_GET:
+ return True
+ return any(path.startswith(p) for p in _RNS_ALLOWED_PREFIXES)
def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at):
@@ -85,7 +96,7 @@ def rns_request_handler(path, data, request_id, link_id, remote_identity, reques
data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""}
method = data.get("method", "GET")
req_path = data.get("path", "/")
- if (method, req_path) not in _RNS_ALLOWED:
+ if not _rns_is_allowed(method, req_path):
return {
"status": 403,
"content_type": "text/plain; charset=utf-8",
diff --git a/src/tinyweb/db.py b/src/tinyweb/db.py
index 0e5cea2..c8138dc 100644
--- a/src/tinyweb/db.py
+++ b/src/tinyweb/db.py
@@ -241,6 +241,13 @@ def init_db():
VALUES (new.id, new.title, new.url, new.note);
END;
""")
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS mesh_sites ("
+ " hash TEXT PRIMARY KEY,"
+ " name TEXT DEFAULT '',"
+ " added_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))"
+ ")"
+ )
# Migrate old subscriptions table if needed
cols = [row[1] for row in db.execute("PRAGMA table_info(subscriptions)").fetchall()]
if "url" in cols and "dest_hash" not in cols:
diff --git a/src/tinyweb/handlers/__init__.py b/src/tinyweb/handlers/__init__.py
index 524ae8d..14a37fd 100644
--- a/src/tinyweb/handlers/__init__.py
+++ b/src/tinyweb/handlers/__init__.py
@@ -6,7 +6,7 @@ from urllib.parse import unquote
from tinyweb.db import get_db, return_db, set_setting
import tinyweb.templates as templates_mod
from tinyweb.templates import esc, wrap_page
-from tinyweb.rns_client import fetch_remote_sites
+from tinyweb.rns_client import fetch_remote_sites, fetch_remote_page
from ._helpers import (
_request_local, _get_csrf_token, _csrf_field, _check_csrf,
@@ -38,6 +38,9 @@ from .data import (
handle_export, handle_import_form, handle_import_submit,
handle_reindex_form, handle_reindex_submit, _reindex_thread,
)
+from .rns import (
+ handle_rns_delete_hash, handle_rns_browse,
+)
forum_plugin = None
@@ -87,6 +90,13 @@ def _dispatch_inner(data):
elif path.startswith("/tags/"):
tag_name = unquote(path[len("/tags/"):])
return handle_tag_browse(tag_name, query) if tag_name else _error(400)
+ elif path.startswith("/rns/"):
+ # /rns//
+ parts = path[len("/rns/"):].split("/", 1)
+ dest_hash = parts[0]
+ if not dest_hash:
+ return _error(404)
+ return handle_rns_browse(path, dest_hash)
elif path == "/reindex":
return handle_reindex_form()
elif path == "/api/sites":
@@ -155,6 +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/delete":
+ return handle_rns_delete_hash(body)
return _error(404)
diff --git a/src/tinyweb/handlers/pages.py b/src/tinyweb/handlers/pages.py
index 4eb2a12..9d7f96c 100644
--- a/src/tinyweb/handlers/pages.py
+++ b/src/tinyweb/handlers/pages.py
@@ -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"add url "
- f"Add a site to your index
"
+ f"add site "
+ f"Add a site to your index — URL or RNS destination hash
"
f''
f'{_csrf_field()}'
- f' '
+ f' '
f' '
f' '
f'tag: private to exclude from sharing '
@@ -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'[{esc(t)}] ' 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' '
f'{esc(r["title"])} {note_html}{tags_html} '
- f'({esc(r["url"])} ) '
+ f'({esc(display_url)} ) '
f'edit '
f'remove '
)
diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py
new file mode 100644
index 0000000..8941f99
--- /dev/null
+++ b/src/tinyweb/handlers/rns.py
@@ -0,0 +1,163 @@
+import json
+import traceback
+from tinyweb.db import get_db, return_db
+from tinyweb.rns_client import fetch_remote_page
+from tinyweb.templates import esc
+
+
+def _get_mesh_sites():
+ db = get_db()
+ try:
+ return db.execute("SELECT hash, name, added_at FROM mesh_sites ORDER BY added_at DESC").fetchall()
+ finally:
+ 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"]*>(.*?) ", body, re.IGNORECASE | re.DOTALL)
+ if m:
+ title = m.group(1).strip()
+ desc = ""
+ m = re.search(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):
+ out = []
+ i = 0
+ while i < len(html):
+ href_start = html.find('href="', i)
+ src_start = html.find('src="', i)
+ action_start = html.find('action="', i)
+
+ candidates = []
+ if href_start >= 0:
+ candidates.append((href_start, "href", 'href="'))
+ if src_start >= 0:
+ candidates.append((src_start, "src", 'src="'))
+ if action_start >= 0:
+ candidates.append((action_start, "action", 'action="'))
+
+ if not candidates:
+ out.append(html[i:])
+ break
+
+ candidates.sort()
+ pos, attr, prefix = candidates[0]
+ out.append(html[i:pos + len(prefix)])
+
+ value_start = pos + len(prefix)
+ value_end = html.find('"', value_start)
+ if value_end < 0:
+ out.append(html[value_start:])
+ break
+ value = html[value_start:value_end]
+
+ if value.startswith("/"):
+ out.append(f"/rns/{dest_hash}{value}")
+ else:
+ out.append(value)
+
+ out.append('"')
+ i = value_end + 1
+
+ return "".join(out)
+
+
+def handle_rns_browse(path, dest_hash):
+ prefix = f"/rns/{dest_hash}"
+ sub_path = path[len(prefix):] if path.startswith(prefix) else "/"
+ if not sub_path:
+ sub_path = "/"
+
+ try:
+ resp = fetch_remote_page(dest_hash, sub_path)
+ except ConnectionError as e:
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": f"could not connect {esc(str(e))}
",
+ "headers": {},
+ }
+ except PermissionError:
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": "forbidden the remote instance blocked this request.
",
+ "headers": {},
+ }
+
+ if resp.get("status") != 200:
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": f"error remote returned status {resp['status']}
",
+ "headers": {},
+ }
+
+ body = resp.get("body", "")
+
+ if resp.get("content_type", "").startswith("application/json"):
+ try:
+ data = json.loads(body)
+ body = f"{esc(json.dumps(data, indent=2))} "
+ except (json.JSONDecodeError, TypeError):
+ body = f"{esc(body[:2000])} "
+
+ body = _rewrite_links(body, dest_hash)
+
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": body,
+ "headers": {},
+ }
diff --git a/src/tinyweb/handlers/search.py b/src/tinyweb/handlers/search.py
index 951b4d4..10b87fd 100644
--- a/src/tinyweb/handlers/search.py
+++ b/src/tinyweb/handlers/search.py
@@ -83,10 +83,17 @@ def handle_search(query):
tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
tags_html = f'{tag_links}
'
snip_html = f' {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''
@@ -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 = (
diff --git a/src/tinyweb/rns_client.py b/src/tinyweb/rns_client.py
index 98df406..a59a654 100644
--- a/src/tinyweb/rns_client.py
+++ b/src/tinyweb/rns_client.py
@@ -12,21 +12,32 @@ _TIMEOUT_TIERS = [
]
-def fetch_remote_sites(dest_hash_hex, since=""):
- """
- Connect to a remote TinyWeb instance over Reticulum and fetch its
- shared sites. Returns the response dict from /api/sites, or raises
- an exception on failure. Pass `since` as ISO timestamp for delta sync.
+# Request path for "/tinyweb" destination
+_RNS_REQUEST_PATH = "/tinyweb"
- Uses progressive timeouts: tries fast first, then retries with longer
- timeouts for slow links (LoRa, multi-hop).
+
+def fetch_remote_sites(dest_hash_hex, since=""):
+ resp = _rns_request(dest_hash_hex, "/api/sites", {"since": [since]} if since else {})
+ return json.loads(resp.get("body", "{}"))
+
+
+def fetch_remote_page(dest_hash_hex, path, query=None):
+ return _rns_request(dest_hash_hex, path, query or {})
+
+
+def _rns_request(dest_hash_hex, path, query=None):
+ """Generic RNS request to a remote TinyWeb instance.
+
+ Connects over RNS, requests the given path, returns the response dict
+ (status, content_type, body, headers). Raises on failure.
+ Uses progressive timeouts: fast first, then slow for LoRa/multi-hop.
"""
last_error = None
for tier in _TIMEOUT_TIERS:
try:
- return _fetch(dest_hash_hex, since, tier)
+ return _fetch(dest_hash_hex, path, query or {}, tier)
except PermissionError:
- raise # Don't retry permission errors
+ raise
except Exception as e:
last_error = e
continue
@@ -35,12 +46,11 @@ def fetch_remote_sites(dest_hash_hex, since=""):
)
-def _fetch(dest_hash_hex, since, timeouts):
- """Single fetch attempt with the given timeout profile."""
+def _fetch(dest_hash_hex, path, query, timeouts):
+ """Single RNS fetch attempt with the given timeout profile."""
dest_hash = bytes.fromhex(dest_hash_hex)
poll = timeouts["poll"]
- # Resolve path if needed
if not RNS.Transport.has_path(dest_hash):
RNS.Transport.request_path(dest_hash)
elapsed = 0
@@ -64,7 +74,6 @@ def _fetch(dest_hash_hex, since, timeouts):
*ASPECTS,
)
- # Establish link
link = RNS.Link(destination)
elapsed = 0
while link.status == RNS.Link.PENDING and elapsed < timeouts["link"]:
@@ -77,17 +86,15 @@ def _fetch(dest_hash_hex, since, timeouts):
)
try:
- query = {"since": [since]} if since else {}
request_data = {
"method": "GET",
- "path": "/api/sites",
+ "path": path,
"query": query,
"body": {},
"gateway_host": "",
}
-
req_timeout = timeouts["request"]
- receipt = link.request("/tinyweb", data=request_data, timeout=req_timeout)
+ receipt = link.request(_RNS_REQUEST_PATH, data=request_data, timeout=req_timeout)
elapsed = 0
done = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED)
@@ -98,10 +105,10 @@ def _fetch(dest_hash_hex, since, timeouts):
if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED):
resp = receipt.get_response()
if resp["status"] == 403:
- raise PermissionError("That instance has sharing disabled.")
+ raise PermissionError("Forbidden")
if resp["status"] != 200:
raise ConnectionError(f"Remote returned status {resp['status']}")
- return json.loads(resp["body"])
+ return resp
else:
raise ConnectionError(
f"Request failed or timed out ({req_timeout}s timeout)"
From ffdd3c176d0fe772f38579b000926943c9b77ae2 Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 18 Jun 2026 17:15:15 +0000
Subject: [PATCH 186/194] rns browser, standalone site server, unified add form
- /rns// proxies pages over RNS with link rewriting
- fetch_remote_page() in rns_client.py for generic RNS page fetching
- mesh_sites table for persisting saved hashes
- standalone site_server.py with its own RNS identity
- tinyweb-site/index.html: SPA with RSS-aware nav (/rns// prefix)
- unified /add form: single input accepts URL or 32-char RNS hash
- RNS add indexes into pages table (fetch root page, extract title/desc)
- rns: URLs displayed in browse/search, linked to /rns//
- expand RNS whitelist: allow GET /, /about, /pages, /tags, /share
---
src/tinyweb/app.py | 23 +++--
src/tinyweb/db.py | 7 ++
src/tinyweb/handlers/__init__.py | 14 ++-
src/tinyweb/handlers/pages.py | 60 +++++++-----
src/tinyweb/handlers/rns.py | 163 +++++++++++++++++++++++++++++++
src/tinyweb/handlers/search.py | 12 ++-
src/tinyweb/rns_client.py | 45 +++++----
7 files changed, 273 insertions(+), 51 deletions(-)
create mode 100644 src/tinyweb/handlers/rns.py
diff --git a/src/tinyweb/app.py b/src/tinyweb/app.py
index b63ed3c..10b756f 100644
--- a/src/tinyweb/app.py
+++ b/src/tinyweb/app.py
@@ -73,11 +73,22 @@ def load_or_create_identity():
return identity
-# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface.
-# Any other method/path is rejected here — CSRF cannot authenticate mesh callers
-# (the attacker controls both the "cookie" and the "form" side of the check), so
-# gating by whitelist is the only safe option.
-_RNS_ALLOWED = {("GET", "/api/sites")}
+# Remote peers on the Reticulum mesh can reach read-only public pages.
+# Only GET is allowed; POST is blocked because CSRF cannot authenticate
+# mesh callers (the attacker controls both the "cookie" and the "form" side).
+_RNS_ALLOWED_GET = {
+ "/", "/about", "/api/sites", "/share/preview",
+}
+
+_RNS_ALLOWED_PREFIXES = ("/pages", "/tags", "/api/sites", "/rns")
+
+
+def _rns_is_allowed(method, path):
+ if method != "GET":
+ return False
+ if path in _RNS_ALLOWED_GET:
+ return True
+ return any(path.startswith(p) for p in _RNS_ALLOWED_PREFIXES)
def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at):
@@ -85,7 +96,7 @@ def rns_request_handler(path, data, request_id, link_id, remote_identity, reques
data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""}
method = data.get("method", "GET")
req_path = data.get("path", "/")
- if (method, req_path) not in _RNS_ALLOWED:
+ if not _rns_is_allowed(method, req_path):
return {
"status": 403,
"content_type": "text/plain; charset=utf-8",
diff --git a/src/tinyweb/db.py b/src/tinyweb/db.py
index 0e5cea2..c8138dc 100644
--- a/src/tinyweb/db.py
+++ b/src/tinyweb/db.py
@@ -241,6 +241,13 @@ def init_db():
VALUES (new.id, new.title, new.url, new.note);
END;
""")
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS mesh_sites ("
+ " hash TEXT PRIMARY KEY,"
+ " name TEXT DEFAULT '',"
+ " added_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))"
+ ")"
+ )
# Migrate old subscriptions table if needed
cols = [row[1] for row in db.execute("PRAGMA table_info(subscriptions)").fetchall()]
if "url" in cols and "dest_hash" not in cols:
diff --git a/src/tinyweb/handlers/__init__.py b/src/tinyweb/handlers/__init__.py
index 524ae8d..14a37fd 100644
--- a/src/tinyweb/handlers/__init__.py
+++ b/src/tinyweb/handlers/__init__.py
@@ -6,7 +6,7 @@ from urllib.parse import unquote
from tinyweb.db import get_db, return_db, set_setting
import tinyweb.templates as templates_mod
from tinyweb.templates import esc, wrap_page
-from tinyweb.rns_client import fetch_remote_sites
+from tinyweb.rns_client import fetch_remote_sites, fetch_remote_page
from ._helpers import (
_request_local, _get_csrf_token, _csrf_field, _check_csrf,
@@ -38,6 +38,9 @@ from .data import (
handle_export, handle_import_form, handle_import_submit,
handle_reindex_form, handle_reindex_submit, _reindex_thread,
)
+from .rns import (
+ handle_rns_delete_hash, handle_rns_browse,
+)
forum_plugin = None
@@ -87,6 +90,13 @@ def _dispatch_inner(data):
elif path.startswith("/tags/"):
tag_name = unquote(path[len("/tags/"):])
return handle_tag_browse(tag_name, query) if tag_name else _error(400)
+ elif path.startswith("/rns/"):
+ # /rns//
+ parts = path[len("/rns/"):].split("/", 1)
+ dest_hash = parts[0]
+ if not dest_hash:
+ return _error(404)
+ return handle_rns_browse(path, dest_hash)
elif path == "/reindex":
return handle_reindex_form()
elif path == "/api/sites":
@@ -155,6 +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/delete":
+ return handle_rns_delete_hash(body)
return _error(404)
diff --git a/src/tinyweb/handlers/pages.py b/src/tinyweb/handlers/pages.py
index 4eb2a12..9d7f96c 100644
--- a/src/tinyweb/handlers/pages.py
+++ b/src/tinyweb/handlers/pages.py
@@ -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"add url "
- f"Add a site to your index
"
+ f"add site "
+ f"Add a site to your index — URL or RNS destination hash
"
f''
f'{_csrf_field()}'
- f' '
+ f' '
f' '
f' '
f'tag: private to exclude from sharing '
@@ -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'[{esc(t)}] ' 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' '
f'{esc(r["title"])} {note_html}{tags_html} '
- f'({esc(r["url"])} ) '
+ f'({esc(display_url)} ) '
f'edit '
f'remove '
)
diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py
new file mode 100644
index 0000000..8941f99
--- /dev/null
+++ b/src/tinyweb/handlers/rns.py
@@ -0,0 +1,163 @@
+import json
+import traceback
+from tinyweb.db import get_db, return_db
+from tinyweb.rns_client import fetch_remote_page
+from tinyweb.templates import esc
+
+
+def _get_mesh_sites():
+ db = get_db()
+ try:
+ return db.execute("SELECT hash, name, added_at FROM mesh_sites ORDER BY added_at DESC").fetchall()
+ finally:
+ 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"]*>(.*?) ", body, re.IGNORECASE | re.DOTALL)
+ if m:
+ title = m.group(1).strip()
+ desc = ""
+ m = re.search(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):
+ out = []
+ i = 0
+ while i < len(html):
+ href_start = html.find('href="', i)
+ src_start = html.find('src="', i)
+ action_start = html.find('action="', i)
+
+ candidates = []
+ if href_start >= 0:
+ candidates.append((href_start, "href", 'href="'))
+ if src_start >= 0:
+ candidates.append((src_start, "src", 'src="'))
+ if action_start >= 0:
+ candidates.append((action_start, "action", 'action="'))
+
+ if not candidates:
+ out.append(html[i:])
+ break
+
+ candidates.sort()
+ pos, attr, prefix = candidates[0]
+ out.append(html[i:pos + len(prefix)])
+
+ value_start = pos + len(prefix)
+ value_end = html.find('"', value_start)
+ if value_end < 0:
+ out.append(html[value_start:])
+ break
+ value = html[value_start:value_end]
+
+ if value.startswith("/"):
+ out.append(f"/rns/{dest_hash}{value}")
+ else:
+ out.append(value)
+
+ out.append('"')
+ i = value_end + 1
+
+ return "".join(out)
+
+
+def handle_rns_browse(path, dest_hash):
+ prefix = f"/rns/{dest_hash}"
+ sub_path = path[len(prefix):] if path.startswith(prefix) else "/"
+ if not sub_path:
+ sub_path = "/"
+
+ try:
+ resp = fetch_remote_page(dest_hash, sub_path)
+ except ConnectionError as e:
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": f"could not connect {esc(str(e))}
",
+ "headers": {},
+ }
+ except PermissionError:
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": "forbidden the remote instance blocked this request.
",
+ "headers": {},
+ }
+
+ if resp.get("status") != 200:
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": f"error remote returned status {resp['status']}
",
+ "headers": {},
+ }
+
+ body = resp.get("body", "")
+
+ if resp.get("content_type", "").startswith("application/json"):
+ try:
+ data = json.loads(body)
+ body = f"{esc(json.dumps(data, indent=2))} "
+ except (json.JSONDecodeError, TypeError):
+ body = f"{esc(body[:2000])} "
+
+ body = _rewrite_links(body, dest_hash)
+
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": body,
+ "headers": {},
+ }
diff --git a/src/tinyweb/handlers/search.py b/src/tinyweb/handlers/search.py
index 951b4d4..10b87fd 100644
--- a/src/tinyweb/handlers/search.py
+++ b/src/tinyweb/handlers/search.py
@@ -83,10 +83,17 @@ def handle_search(query):
tag_links = " ".join(f'[{esc(t)}] ' for t in tags)
tags_html = f'{tag_links}
'
snip_html = f' {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''
@@ -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 = (
diff --git a/src/tinyweb/rns_client.py b/src/tinyweb/rns_client.py
index 98df406..a59a654 100644
--- a/src/tinyweb/rns_client.py
+++ b/src/tinyweb/rns_client.py
@@ -12,21 +12,32 @@ _TIMEOUT_TIERS = [
]
-def fetch_remote_sites(dest_hash_hex, since=""):
- """
- Connect to a remote TinyWeb instance over Reticulum and fetch its
- shared sites. Returns the response dict from /api/sites, or raises
- an exception on failure. Pass `since` as ISO timestamp for delta sync.
+# Request path for "/tinyweb" destination
+_RNS_REQUEST_PATH = "/tinyweb"
- Uses progressive timeouts: tries fast first, then retries with longer
- timeouts for slow links (LoRa, multi-hop).
+
+def fetch_remote_sites(dest_hash_hex, since=""):
+ resp = _rns_request(dest_hash_hex, "/api/sites", {"since": [since]} if since else {})
+ return json.loads(resp.get("body", "{}"))
+
+
+def fetch_remote_page(dest_hash_hex, path, query=None):
+ return _rns_request(dest_hash_hex, path, query or {})
+
+
+def _rns_request(dest_hash_hex, path, query=None):
+ """Generic RNS request to a remote TinyWeb instance.
+
+ Connects over RNS, requests the given path, returns the response dict
+ (status, content_type, body, headers). Raises on failure.
+ Uses progressive timeouts: fast first, then slow for LoRa/multi-hop.
"""
last_error = None
for tier in _TIMEOUT_TIERS:
try:
- return _fetch(dest_hash_hex, since, tier)
+ return _fetch(dest_hash_hex, path, query or {}, tier)
except PermissionError:
- raise # Don't retry permission errors
+ raise
except Exception as e:
last_error = e
continue
@@ -35,12 +46,11 @@ def fetch_remote_sites(dest_hash_hex, since=""):
)
-def _fetch(dest_hash_hex, since, timeouts):
- """Single fetch attempt with the given timeout profile."""
+def _fetch(dest_hash_hex, path, query, timeouts):
+ """Single RNS fetch attempt with the given timeout profile."""
dest_hash = bytes.fromhex(dest_hash_hex)
poll = timeouts["poll"]
- # Resolve path if needed
if not RNS.Transport.has_path(dest_hash):
RNS.Transport.request_path(dest_hash)
elapsed = 0
@@ -64,7 +74,6 @@ def _fetch(dest_hash_hex, since, timeouts):
*ASPECTS,
)
- # Establish link
link = RNS.Link(destination)
elapsed = 0
while link.status == RNS.Link.PENDING and elapsed < timeouts["link"]:
@@ -77,17 +86,15 @@ def _fetch(dest_hash_hex, since, timeouts):
)
try:
- query = {"since": [since]} if since else {}
request_data = {
"method": "GET",
- "path": "/api/sites",
+ "path": path,
"query": query,
"body": {},
"gateway_host": "",
}
-
req_timeout = timeouts["request"]
- receipt = link.request("/tinyweb", data=request_data, timeout=req_timeout)
+ receipt = link.request(_RNS_REQUEST_PATH, data=request_data, timeout=req_timeout)
elapsed = 0
done = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED)
@@ -98,10 +105,10 @@ def _fetch(dest_hash_hex, since, timeouts):
if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED):
resp = receipt.get_response()
if resp["status"] == 403:
- raise PermissionError("That instance has sharing disabled.")
+ raise PermissionError("Forbidden")
if resp["status"] != 200:
raise ConnectionError(f"Remote returned status {resp['status']}")
- return json.loads(resp["body"])
+ return resp
else:
raise ConnectionError(
f"Request failed or timed out ({req_timeout}s timeout)"
From ba1b8a783a05b7f6172040d4b6e6c8f602cb6dd1 Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 19 Jun 2026 02:17:11 +0000
Subject: [PATCH 187/194] base tag + LRU cache for RNS browse, sync status
fixes
---
src/tinyweb/handlers/rns.py | 100 +++++++++++++++-----------
src/tinyweb/handlers/subscriptions.py | 25 ++++++-
2 files changed, 79 insertions(+), 46 deletions(-)
diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py
index 8941f99..53a5616 100644
--- a/src/tinyweb/handlers/rns.py
+++ b/src/tinyweb/handlers/rns.py
@@ -1,10 +1,54 @@
import json
+import time
+import threading
import traceback
from tinyweb.db import get_db, return_db
from tinyweb.rns_client import fetch_remote_page
from tinyweb.templates import esc
+class _PageCache:
+ def __init__(self, maxsize=50, ttl=300):
+ self._maxsize = maxsize
+ self._ttl = ttl
+ self._cache = {}
+ self._lock = threading.Lock()
+
+ def get(self, key):
+ with self._lock:
+ entry = self._cache.get(key)
+ if entry is None:
+ return None
+ if time.time() - entry["time"] > self._ttl:
+ del self._cache[key]
+ return None
+ self._cache.pop(key)
+ self._cache[key] = entry
+ return entry["value"]
+
+ def put(self, key, value):
+ with self._lock:
+ if key in self._cache:
+ self._cache.pop(key)
+ elif len(self._cache) >= self._maxsize:
+ oldest = next(iter(self._cache))
+ del self._cache[oldest]
+ self._cache[key] = {"value": value, "time": time.time()}
+
+
+_page_cache = _PageCache()
+
+
+def _inject_base_tag(html, dest_hash):
+ base = f' '
+ head_start = html.find("= 0:
+ close = html.find(">", head_start)
+ if close >= 0:
+ return html[:close + 1] + base + html[close + 1:]
+ return f"{base}{html}"
+
+
def _get_mesh_sites():
db = get_db()
try:
@@ -71,54 +115,22 @@ def handle_rns_delete_hash(body):
return _redirect("/")
-def _rewrite_links(html, dest_hash):
- out = []
- i = 0
- while i < len(html):
- href_start = html.find('href="', i)
- src_start = html.find('src="', i)
- action_start = html.find('action="', i)
-
- candidates = []
- if href_start >= 0:
- candidates.append((href_start, "href", 'href="'))
- if src_start >= 0:
- candidates.append((src_start, "src", 'src="'))
- if action_start >= 0:
- candidates.append((action_start, "action", 'action="'))
-
- if not candidates:
- out.append(html[i:])
- break
-
- candidates.sort()
- pos, attr, prefix = candidates[0]
- out.append(html[i:pos + len(prefix)])
-
- value_start = pos + len(prefix)
- value_end = html.find('"', value_start)
- if value_end < 0:
- out.append(html[value_start:])
- break
- value = html[value_start:value_end]
-
- if value.startswith("/"):
- out.append(f"/rns/{dest_hash}{value}")
- else:
- out.append(value)
-
- out.append('"')
- i = value_end + 1
-
- return "".join(out)
-
-
def handle_rns_browse(path, dest_hash):
prefix = f"/rns/{dest_hash}"
sub_path = path[len(prefix):] if path.startswith(prefix) else "/"
if not sub_path:
sub_path = "/"
+ cache_key = (dest_hash, sub_path)
+ cached = _page_cache.get(cache_key)
+ if cached is not None:
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": cached,
+ "headers": {},
+ }
+
try:
resp = fetch_remote_page(dest_hash, sub_path)
except ConnectionError as e:
@@ -152,8 +164,10 @@ def handle_rns_browse(path, dest_hash):
body = f"{esc(json.dumps(data, indent=2))} "
except (json.JSONDecodeError, TypeError):
body = f"{esc(body[:2000])} "
+ else:
+ body = _inject_base_tag(body, dest_hash)
- body = _rewrite_links(body, dest_hash)
+ _page_cache.put(cache_key, body)
return {
"status": 200,
diff --git a/src/tinyweb/handlers/subscriptions.py b/src/tinyweb/handlers/subscriptions.py
index 0113d07..a7ba389 100644
--- a/src/tinyweb/handlers/subscriptions.py
+++ b/src/tinyweb/handlers/subscriptions.py
@@ -1,4 +1,5 @@
import threading
+import time
from datetime import datetime
from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
@@ -10,6 +11,8 @@ from ._helpers import (
)
_sync_threads = {}
+_sync_starts = {}
+_SYNC_TIMEOUT = 120
MAX_API_SITES = 5000
MAX_BROWSE = 5000
@@ -142,6 +145,13 @@ def handle_subscriptions(msg=""):
subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
finally:
return_db(db)
+ now_t = time.time()
+ for sub_id, start_t in list(_sync_starts.items()):
+ if now_t - start_t > _SYNC_TIMEOUT:
+ set_setting(f"sync_status_{sub_id}", "error:Timed out")
+ _sync_threads.pop(sub_id, None)
+ _sync_starts.pop(sub_id, None)
+
cards = ""
for s in subs:
sub_id = s["id"]
@@ -152,6 +162,9 @@ def handle_subscriptions(msg=""):
if is_syncing:
status_html = 'syncing...
'
+ elif sync_status.startswith("done:"):
+ count = sync_status[5:]
+ status_html = f'synced {esc(count)} site(s)
'
elif sync_status.startswith("error:"):
err_msg = sync_status[6:]
status_html = f'{esc(err_msg)}
'
@@ -349,9 +362,10 @@ def handle_subscription_pick(body):
def _sync_subscription(sub_id):
- set_setting(f"sync_status_{sub_id}", "syncing")
- db = get_db()
+ db = None
try:
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ db = get_db()
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
if not sub:
set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
@@ -407,13 +421,17 @@ def _sync_subscription(sub_id):
except Exception as e:
set_setting(f"sync_status_{sub_id}", f"error:{e}")
finally:
- return_db(db)
+ if db:
+ return_db(db)
+ _sync_threads.pop(sub_id, None)
+ _sync_starts.pop(sub_id, None)
def handle_subscription_sync(sub_id):
if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
return _redirect("/subscriptions")
set_setting(f"sync_status_{sub_id}", "syncing")
+ _sync_starts[sub_id] = time.time()
t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
_sync_threads[sub_id] = t
t.start()
@@ -454,6 +472,7 @@ def handle_subscription_syncall():
if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
continue
set_setting(f"sync_status_{sub_id}", "syncing")
+ _sync_starts[sub_id] = time.time()
t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
_sync_threads[sub_id] = t
t.start()
From ff71cba002980081d6fabb7d81dea9fb9843afd9 Mon Sep 17 00:00:00 2001
From: blankie
Date: Fri, 19 Jun 2026 02:17:11 +0000
Subject: [PATCH 188/194] base tag + LRU cache for RNS browse, sync status
fixes
---
src/tinyweb/handlers/rns.py | 100 +++++++++++++++-----------
src/tinyweb/handlers/subscriptions.py | 25 ++++++-
2 files changed, 79 insertions(+), 46 deletions(-)
diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py
index 8941f99..53a5616 100644
--- a/src/tinyweb/handlers/rns.py
+++ b/src/tinyweb/handlers/rns.py
@@ -1,10 +1,54 @@
import json
+import time
+import threading
import traceback
from tinyweb.db import get_db, return_db
from tinyweb.rns_client import fetch_remote_page
from tinyweb.templates import esc
+class _PageCache:
+ def __init__(self, maxsize=50, ttl=300):
+ self._maxsize = maxsize
+ self._ttl = ttl
+ self._cache = {}
+ self._lock = threading.Lock()
+
+ def get(self, key):
+ with self._lock:
+ entry = self._cache.get(key)
+ if entry is None:
+ return None
+ if time.time() - entry["time"] > self._ttl:
+ del self._cache[key]
+ return None
+ self._cache.pop(key)
+ self._cache[key] = entry
+ return entry["value"]
+
+ def put(self, key, value):
+ with self._lock:
+ if key in self._cache:
+ self._cache.pop(key)
+ elif len(self._cache) >= self._maxsize:
+ oldest = next(iter(self._cache))
+ del self._cache[oldest]
+ self._cache[key] = {"value": value, "time": time.time()}
+
+
+_page_cache = _PageCache()
+
+
+def _inject_base_tag(html, dest_hash):
+ base = f' '
+ head_start = html.find("= 0:
+ close = html.find(">", head_start)
+ if close >= 0:
+ return html[:close + 1] + base + html[close + 1:]
+ return f"{base}{html}"
+
+
def _get_mesh_sites():
db = get_db()
try:
@@ -71,54 +115,22 @@ def handle_rns_delete_hash(body):
return _redirect("/")
-def _rewrite_links(html, dest_hash):
- out = []
- i = 0
- while i < len(html):
- href_start = html.find('href="', i)
- src_start = html.find('src="', i)
- action_start = html.find('action="', i)
-
- candidates = []
- if href_start >= 0:
- candidates.append((href_start, "href", 'href="'))
- if src_start >= 0:
- candidates.append((src_start, "src", 'src="'))
- if action_start >= 0:
- candidates.append((action_start, "action", 'action="'))
-
- if not candidates:
- out.append(html[i:])
- break
-
- candidates.sort()
- pos, attr, prefix = candidates[0]
- out.append(html[i:pos + len(prefix)])
-
- value_start = pos + len(prefix)
- value_end = html.find('"', value_start)
- if value_end < 0:
- out.append(html[value_start:])
- break
- value = html[value_start:value_end]
-
- if value.startswith("/"):
- out.append(f"/rns/{dest_hash}{value}")
- else:
- out.append(value)
-
- out.append('"')
- i = value_end + 1
-
- return "".join(out)
-
-
def handle_rns_browse(path, dest_hash):
prefix = f"/rns/{dest_hash}"
sub_path = path[len(prefix):] if path.startswith(prefix) else "/"
if not sub_path:
sub_path = "/"
+ cache_key = (dest_hash, sub_path)
+ cached = _page_cache.get(cache_key)
+ if cached is not None:
+ return {
+ "status": 200,
+ "content_type": "text/html; charset=utf-8",
+ "body": cached,
+ "headers": {},
+ }
+
try:
resp = fetch_remote_page(dest_hash, sub_path)
except ConnectionError as e:
@@ -152,8 +164,10 @@ def handle_rns_browse(path, dest_hash):
body = f"{esc(json.dumps(data, indent=2))} "
except (json.JSONDecodeError, TypeError):
body = f"{esc(body[:2000])} "
+ else:
+ body = _inject_base_tag(body, dest_hash)
- body = _rewrite_links(body, dest_hash)
+ _page_cache.put(cache_key, body)
return {
"status": 200,
diff --git a/src/tinyweb/handlers/subscriptions.py b/src/tinyweb/handlers/subscriptions.py
index 0113d07..a7ba389 100644
--- a/src/tinyweb/handlers/subscriptions.py
+++ b/src/tinyweb/handlers/subscriptions.py
@@ -1,4 +1,5 @@
import threading
+import time
from datetime import datetime
from tinyweb.db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
@@ -10,6 +11,8 @@ from ._helpers import (
)
_sync_threads = {}
+_sync_starts = {}
+_SYNC_TIMEOUT = 120
MAX_API_SITES = 5000
MAX_BROWSE = 5000
@@ -142,6 +145,13 @@ def handle_subscriptions(msg=""):
subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
finally:
return_db(db)
+ now_t = time.time()
+ for sub_id, start_t in list(_sync_starts.items()):
+ if now_t - start_t > _SYNC_TIMEOUT:
+ set_setting(f"sync_status_{sub_id}", "error:Timed out")
+ _sync_threads.pop(sub_id, None)
+ _sync_starts.pop(sub_id, None)
+
cards = ""
for s in subs:
sub_id = s["id"]
@@ -152,6 +162,9 @@ def handle_subscriptions(msg=""):
if is_syncing:
status_html = 'syncing...
'
+ elif sync_status.startswith("done:"):
+ count = sync_status[5:]
+ status_html = f'synced {esc(count)} site(s)
'
elif sync_status.startswith("error:"):
err_msg = sync_status[6:]
status_html = f'{esc(err_msg)}
'
@@ -349,9 +362,10 @@ def handle_subscription_pick(body):
def _sync_subscription(sub_id):
- set_setting(f"sync_status_{sub_id}", "syncing")
- db = get_db()
+ db = None
try:
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ db = get_db()
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
if not sub:
set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
@@ -407,13 +421,17 @@ def _sync_subscription(sub_id):
except Exception as e:
set_setting(f"sync_status_{sub_id}", f"error:{e}")
finally:
- return_db(db)
+ if db:
+ return_db(db)
+ _sync_threads.pop(sub_id, None)
+ _sync_starts.pop(sub_id, None)
def handle_subscription_sync(sub_id):
if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
return _redirect("/subscriptions")
set_setting(f"sync_status_{sub_id}", "syncing")
+ _sync_starts[sub_id] = time.time()
t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
_sync_threads[sub_id] = t
t.start()
@@ -454,6 +472,7 @@ def handle_subscription_syncall():
if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
continue
set_setting(f"sync_status_{sub_id}", "syncing")
+ _sync_starts[sub_id] = time.time()
t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
_sync_threads[sub_id] = t
t.start()
From 4189bd7f2486dce444464256a3bffc6ca1442e73 Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 2 Jul 2026 23:27:05 +0000
Subject: [PATCH 189/194] rns: store cleaned text instead of raw HTML body
---
src/tinyweb/handlers/pages.py | 2 +-
src/tinyweb/handlers/rns.py | 34 +++++++++++++++++++++-------------
2 files changed, 22 insertions(+), 14 deletions(-)
diff --git a/src/tinyweb/handlers/pages.py b/src/tinyweb/handlers/pages.py
index 9d7f96c..05d885a 100644
--- a/src/tinyweb/handlers/pages.py
+++ b/src/tinyweb/handlers/pages.py
@@ -90,7 +90,7 @@ def handle_add_submit(body):
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")):
+ if any(x in error_msg for x in ("block", "cloudflare", "403", "429", "ssl", "handshake", "max retries", "timeout", "connection")):
return _respond(
f"add url (manual entry) "
f"{esc(url)} blocks automated access. "
diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py
index 53a5616..086acde 100644
--- a/src/tinyweb/handlers/rns.py
+++ b/src/tinyweb/handlers/rns.py
@@ -2,6 +2,8 @@ import json
import time
import threading
import traceback
+import datetime
+from bs4 import BeautifulSoup
from tinyweb.db import get_db, return_db
from tinyweb.rns_client import fetch_remote_page
from tinyweb.templates import esc
@@ -69,26 +71,32 @@ def handle_rns_add_hash(dest_hash, name=""):
try:
resp = fetch_remote_page(dest_hash, "/")
if resp.get("status") == 200:
- body = resp.get("body", "")
+ body_raw = resp.get("body", "")
+ soup = BeautifulSoup(body_raw, 'html.parser')
+ for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "aside"]):
+ tag.decompose()
+ cleaned = soup.get_text(separator=" ", strip=True)
+
title = name or dest_hash[:16]
- import re
- m = re.search(r"
]*>(.*?) ", body, re.IGNORECASE | re.DOTALL)
- if m:
- title = m.group(1).strip()
+ if soup.title and soup.title.string:
+ title = soup.title.string.strip()
+
desc = ""
- m = re.search(r' ]+>", " ", body)
- text = re.sub(r"\s+", " ", text).strip()
- desc = text[:200].strip()
+ m = soup.find("meta", attrs={"property": "og:description"})
+ if m and m.get("content"):
+ desc = m["content"].strip()
+ if not desc:
+ desc = cleaned[: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),
+ (url, title, cleaned, now, desc),
)
else:
errors.append(f"Remote returned status {resp.get('status')}")
From 83a5c6728c23b209dd22c07ea84580646593baf5 Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 2 Jul 2026 23:27:05 +0000
Subject: [PATCH 190/194] rns: store cleaned text instead of raw HTML body
---
src/tinyweb/handlers/pages.py | 2 +-
src/tinyweb/handlers/rns.py | 34 +++++++++++++++++++++-------------
2 files changed, 22 insertions(+), 14 deletions(-)
diff --git a/src/tinyweb/handlers/pages.py b/src/tinyweb/handlers/pages.py
index 9d7f96c..05d885a 100644
--- a/src/tinyweb/handlers/pages.py
+++ b/src/tinyweb/handlers/pages.py
@@ -90,7 +90,7 @@ def handle_add_submit(body):
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")):
+ if any(x in error_msg for x in ("block", "cloudflare", "403", "429", "ssl", "handshake", "max retries", "timeout", "connection")):
return _respond(
f"add url (manual entry) "
f"{esc(url)} blocks automated access. "
diff --git a/src/tinyweb/handlers/rns.py b/src/tinyweb/handlers/rns.py
index 53a5616..086acde 100644
--- a/src/tinyweb/handlers/rns.py
+++ b/src/tinyweb/handlers/rns.py
@@ -2,6 +2,8 @@ import json
import time
import threading
import traceback
+import datetime
+from bs4 import BeautifulSoup
from tinyweb.db import get_db, return_db
from tinyweb.rns_client import fetch_remote_page
from tinyweb.templates import esc
@@ -69,26 +71,32 @@ def handle_rns_add_hash(dest_hash, name=""):
try:
resp = fetch_remote_page(dest_hash, "/")
if resp.get("status") == 200:
- body = resp.get("body", "")
+ body_raw = resp.get("body", "")
+ soup = BeautifulSoup(body_raw, 'html.parser')
+ for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "aside"]):
+ tag.decompose()
+ cleaned = soup.get_text(separator=" ", strip=True)
+
title = name or dest_hash[:16]
- import re
- m = re.search(r"
]*>(.*?) ", body, re.IGNORECASE | re.DOTALL)
- if m:
- title = m.group(1).strip()
+ if soup.title and soup.title.string:
+ title = soup.title.string.strip()
+
desc = ""
- m = re.search(r' ]+>", " ", body)
- text = re.sub(r"\s+", " ", text).strip()
- desc = text[:200].strip()
+ m = soup.find("meta", attrs={"property": "og:description"})
+ if m and m.get("content"):
+ desc = m["content"].strip()
+ if not desc:
+ desc = cleaned[: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),
+ (url, title, cleaned, now, desc),
)
else:
errors.append(f"Remote returned status {resp.get('status')}")
From 599d5a491d40d2a26616d34e9c7d8fde9f7bceff Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 2 Jul 2026 23:27:42 +0000
Subject: [PATCH 191/194] readme: add RNS browsing to features, update storage
estimates for cleaned-text model
---
README.md | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index c52f06e..73156d0 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@ Code generated by LLMs. Built by one person.
## Features
- **Personal search index** — Save pages you find valuable, search them with full-text search (SQLite FTS5)
+- **RNS live browsing** — Browse any RNS site through your instance via ` ` tag injection, with LRU caching
+- **Unified add form** — Add HTTP URLs or RNS destination hashes through a single form; auto-detection handles both
- **Tagging** — Organize saved pages with comma-separated tags
- **Bookmarklet** — One-click indexing from any browser tab
- **Subscriptions** — Subscribe to friends' TinyWeb instances over Reticulum and search their indexes alongside yours
@@ -117,14 +119,14 @@ Data persists in the `tinyweb-data` named volume. On Linux with LAN auto-discove
## Storage Estimates
-Average web page content is ~15KB per page:
+Pages are stored as cleaned text (HTML tags stripped, boilerplate removed) — typically 5-15 KB per page across both HTTP and RNS sources:
| Pages | Database | Embeddings* | Total |
|-------|----------|------------|-------|
-| 10,000 | 150MB | 80MB | ~250MB |
-| 100,000 | 1.5GB | 800MB | ~2.5GB |
-| 500,000 | 7.5GB | 4GB | ~12GB |
-| 1,000,000 | 15GB | 8GB | ~25GB |
+| 10,000 | ~100MB | 80MB | ~180MB |
+| 100,000 | ~1GB | 800MB | ~1.8GB |
+| 500,000 | ~5GB | 4GB | ~9GB |
+| 1,000,000 | ~10GB | 8GB | ~18GB |
*Embeddings require semantic search to be enabled. With compression enabled (Settings > Search > AI), embeddings use ~50% less storage.
From dd2f1f11912426c5a2cb616ad58c1727bde34405 Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 2 Jul 2026 23:27:42 +0000
Subject: [PATCH 192/194] readme: add RNS browsing to features, update storage
estimates for cleaned-text model
---
README.md | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index c52f06e..73156d0 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@ Code generated by LLMs. Built by one person.
## Features
- **Personal search index** — Save pages you find valuable, search them with full-text search (SQLite FTS5)
+- **RNS live browsing** — Browse any RNS site through your instance via ` ` tag injection, with LRU caching
+- **Unified add form** — Add HTTP URLs or RNS destination hashes through a single form; auto-detection handles both
- **Tagging** — Organize saved pages with comma-separated tags
- **Bookmarklet** — One-click indexing from any browser tab
- **Subscriptions** — Subscribe to friends' TinyWeb instances over Reticulum and search their indexes alongside yours
@@ -117,14 +119,14 @@ Data persists in the `tinyweb-data` named volume. On Linux with LAN auto-discove
## Storage Estimates
-Average web page content is ~15KB per page:
+Pages are stored as cleaned text (HTML tags stripped, boilerplate removed) — typically 5-15 KB per page across both HTTP and RNS sources:
| Pages | Database | Embeddings* | Total |
|-------|----------|------------|-------|
-| 10,000 | 150MB | 80MB | ~250MB |
-| 100,000 | 1.5GB | 800MB | ~2.5GB |
-| 500,000 | 7.5GB | 4GB | ~12GB |
-| 1,000,000 | 15GB | 8GB | ~25GB |
+| 10,000 | ~100MB | 80MB | ~180MB |
+| 100,000 | ~1GB | 800MB | ~1.8GB |
+| 500,000 | ~5GB | 4GB | ~9GB |
+| 1,000,000 | ~10GB | 8GB | ~18GB |
*Embeddings require semantic search to be enabled. With compression enabled (Settings > Search > AI), embeddings use ~50% less storage.
From b43af14c7c4496c50cf09a94768e7a672842d142 Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 2 Jul 2026 23:32:58 +0000
Subject: [PATCH 193/194] security: replace personal email with confidential
issue reporting
---
SECURITY.md | 27 ++++++++++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/SECURITY.md b/SECURITY.md
index 78d2c70..7001dbf 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1 +1,26 @@
-If you find a security issue, report it privately by emailing security@tinyweb. Please don't file a public issue.
+# Security Policy
+
+TinyWeb is a self-hosted personal search engine with no authentication. It is
+bound to localhost by default. Do not expose it to the internet without a
+reverse proxy with authentication.
+
+## Supported Versions
+
+Security fixes are handled on the default branch.
+
+## Deployment Guidance
+
+- Keep TinyWeb bound to `127.0.0.1` unless you need LAN access
+- If binding to `0.0.0.0`, put a reverse proxy with auth in front (Caddy, nginx)
+- Always use HTTPS when exposing beyond localhost
+- Protect `~/.tinyweb/tinyweb_identity` — losing it changes your destination hash
+- Protect `~/.tinyweb/index.db` — contains your full browsing history
+- Never commit `*.db`, `tinyweb_identity`, `.env`, or `models/` to version control
+- Keep `--bind 0.0.0.0` usage to trusted networks only
+- The bookmarklet token is sent as a plain URL parameter — treat it as a secret
+- Forum plugin: moderation is gossip-based; block lists can be manipulated
+
+## Reporting
+
+Report vulnerabilities privately by creating a confidential issue on the
+repository. Do not file a public issue until the report has been acknowledged.
From 00f9286eb822d0520f7c33146934006930dfce8c Mon Sep 17 00:00:00 2001
From: blankie
Date: Thu, 2 Jul 2026 23:32:58 +0000
Subject: [PATCH 194/194] security: replace personal email with confidential
issue reporting
---
SECURITY.md | 27 ++++++++++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/SECURITY.md b/SECURITY.md
index f294f81..7001dbf 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1 +1,26 @@
-If you find a security issue, report it privately by emailing blankie@tuta.com. Please don't file a public issue.
+# Security Policy
+
+TinyWeb is a self-hosted personal search engine with no authentication. It is
+bound to localhost by default. Do not expose it to the internet without a
+reverse proxy with authentication.
+
+## Supported Versions
+
+Security fixes are handled on the default branch.
+
+## Deployment Guidance
+
+- Keep TinyWeb bound to `127.0.0.1` unless you need LAN access
+- If binding to `0.0.0.0`, put a reverse proxy with auth in front (Caddy, nginx)
+- Always use HTTPS when exposing beyond localhost
+- Protect `~/.tinyweb/tinyweb_identity` — losing it changes your destination hash
+- Protect `~/.tinyweb/index.db` — contains your full browsing history
+- Never commit `*.db`, `tinyweb_identity`, `.env`, or `models/` to version control
+- Keep `--bind 0.0.0.0` usage to trusted networks only
+- The bookmarklet token is sent as a plain URL parameter — treat it as a secret
+- Forum plugin: moderation is gossip-based; block lists can be manipulated
+
+## Reporting
+
+Report vulnerabilities privately by creating a confidential issue on the
+repository. Do not file a public issue until the report has been acknowledged.