integrated the forum plugin
This commit is contained in:
parent
4a0214f020
commit
46cd28ba54
5 changed files with 122 additions and 4 deletions
24
README.md
24
README.md
|
|
@ -11,6 +11,7 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
|
||||||
- **Custom templates** — Full HTML/CSS/JS template editor to personalize your instance
|
- **Custom templates** — Full HTML/CSS/JS template editor to personalize your instance
|
||||||
- **Import/export** — JSON-based backup and restore
|
- **Import/export** — JSON-based backup and restore
|
||||||
- **Mesh-native** — Works over Reticulum without the internet; encrypted and decentralized by default
|
- **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)
|
||||||
|
|
||||||
## Performance & Scale
|
## Performance & Scale
|
||||||
|
|
||||||
|
|
@ -107,6 +108,7 @@ Your data is stored in `~/.tinyweb/`:
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `index.db` | SQLite database with your indexed pages |
|
| `index.db` | SQLite database with your indexed pages |
|
||||||
| `tinyweb_identity` | Your Reticulum identity (keep safe!) |
|
| `tinyweb_identity` | Your Reticulum identity (keep safe!) |
|
||||||
|
| `forum.db` | Forum plugin database (only if forum is enabled) |
|
||||||
| `models/` | Downloaded AI models for semantic search |
|
| `models/` | Downloaded AI models for semantic search |
|
||||||
| `index.hnsw` | Semantic search index |
|
| `index.hnsw` | Semantic search index |
|
||||||
|
|
||||||
|
|
@ -118,6 +120,7 @@ Back up the whole `~/.tinyweb/` directory periodically. The two files that matte
|
||||||
|
|
||||||
- **`tinyweb_identity`** is your permanent mesh identity. If you lose it, your destination hash changes and every subscriber has to re-subscribe to the new one. Keep it somewhere you trust; the file is `0600` by default.
|
- **`tinyweb_identity`** is your permanent mesh identity. If you lose it, your destination hash changes and every subscriber has to re-subscribe to the new one. Keep it somewhere you trust; the file is `0600` by default.
|
||||||
- **`index.db`** is your full reading history — every page, note, tag, and synced remote page. Losing it loses everything you've curated.
|
- **`index.db`** is your full reading history — every page, note, tag, and synced remote page. Losing it loses everything you've curated.
|
||||||
|
- **`forum.db`** (if the forum plugin is enabled) — all threads, posts, upvotes, and moderation settings. Losing it loses your forum data.
|
||||||
|
|
||||||
`models/` and `index.hnsw` are re-derivable (the model will re-download, and the HNSW index rebuilds from the database on next startup with semantic search enabled) so they don't need to be backed up.
|
`models/` and `index.hnsw` are re-derivable (the model will re-download, and the HNSW index rebuilds from the database on next startup with semantic search enabled) so they don't need to be backed up.
|
||||||
|
|
||||||
|
|
@ -176,6 +179,27 @@ 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
|
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`
|
4. **Customize** — Edit your site name, HTML template, and sharing settings on `/style`
|
||||||
|
|
||||||
|
## Forum plugin
|
||||||
|
|
||||||
|
TinyWeb ships with an optional [tinyweb-forum](https://git.derickphan.com/lichenblankie/tinyweb-forum) plugin — a decentralized link-sharing discussion board that runs in-process alongside TinyWeb.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install tinyweb-forum
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable it on the `/style` page under "Forum". A "Forum" link will appear in the navigation bar.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
- Threads and posts are stored in `~/.tinyweb/forum.db` (separate from your search index)
|
||||||
|
- Instances sync forum content over Reticulum every 5 minutes
|
||||||
|
- Authors are identified by a short pseudonymous identity hash (no accounts, no sign-up)
|
||||||
|
- Moderation is local: block authors, mute threads, keyword filters, and gossip block lists with peers
|
||||||
|
|
||||||
|
For full feature docs, see the [tinyweb-forum README](https://git.derickphan.com/lichenblankie/tinyweb-forum).
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
20
app.py
20
app.py
|
|
@ -8,6 +8,8 @@ from http.server import HTTPServer
|
||||||
|
|
||||||
from db import init_db, get_setting, set_setting
|
from db import init_db, get_setting, set_setting
|
||||||
from handlers import dispatch_request
|
from handlers import dispatch_request
|
||||||
|
import handlers as handlers_mod
|
||||||
|
import templates as templates_mod
|
||||||
import gateway
|
import gateway
|
||||||
from gateway import GatewayState, GatewayHandler
|
from gateway import GatewayState, GatewayHandler
|
||||||
|
|
||||||
|
|
@ -31,6 +33,7 @@ def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"):
|
||||||
for port in range(start, start + max_attempts):
|
for port in range(start, start + max_attempts):
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
s.bind((host, port))
|
s.bind((host, port))
|
||||||
return port
|
return port
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|
@ -97,6 +100,7 @@ def rns_request_handler(path, data, request_id, link_id, remote_identity, reques
|
||||||
def start_gateway(reticulum, bind_host="127.0.0.1"):
|
def start_gateway(reticulum, bind_host="127.0.0.1"):
|
||||||
GatewayState.reticulum = reticulum
|
GatewayState.reticulum = reticulum
|
||||||
GatewayState.local_dispatch = dispatch_request
|
GatewayState.local_dispatch = dispatch_request
|
||||||
|
HTTPServer.allow_reuse_address = True
|
||||||
server = HTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
|
server = HTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
@ -271,6 +275,22 @@ def main():
|
||||||
allow=RNS.Destination.ALLOW_ALL,
|
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
|
# Brief delay to ensure all interfaces (especially TCP) are fully ready
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
destination.announce()
|
destination.announce()
|
||||||
|
|
|
||||||
77
handlers.py
77
handlers.py
|
|
@ -6,9 +6,11 @@ from datetime import datetime
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
|
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 templates import esc, wrap_page, DEFAULT_TEMPLATE
|
||||||
from rns_client import fetch_remote_sites
|
from rns_client import fetch_remote_sites
|
||||||
|
|
||||||
|
forum_plugin = None
|
||||||
_request_local = threading.local()
|
_request_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -360,7 +362,7 @@ def handle_search(query):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def handle_add_form(msg="", action_type="index"):
|
def handle_add_form(msg="", action_type="index", prefill_url=""):
|
||||||
if action_type == "subscribe":
|
if action_type == "subscribe":
|
||||||
return _respond(
|
return _respond(
|
||||||
f"<h1>subscribe</h1>"
|
f"<h1>subscribe</h1>"
|
||||||
|
|
@ -374,12 +376,13 @@ def handle_add_form(msg="", action_type="index"):
|
||||||
f"<p>{msg}</p>"
|
f"<p>{msg}</p>"
|
||||||
f'<a href="/">back</a>'
|
f'<a href="/">back</a>'
|
||||||
)
|
)
|
||||||
|
url_value = f'value="{esc(prefill_url)}" ' if prefill_url else ""
|
||||||
return _respond(
|
return _respond(
|
||||||
f"<h1>add url</h1>"
|
f"<h1>add url</h1>"
|
||||||
f"<p>Add a site to your index</p>"
|
f"<p>Add a site to your index</p>"
|
||||||
f'<form method="post" action="/add">'
|
f'<form method="post" action="/add">'
|
||||||
f'{_csrf_field()}'
|
f'{_csrf_field()}'
|
||||||
f'<input name="url" placeholder="https://example.com" size="50"><br><br>'
|
f'<input name="url" placeholder="https://example.com" size="50" {url_value}><br><br>'
|
||||||
f'<input name="note" placeholder="why are you saving this? (optional)" size="50"><br><br>'
|
f'<input name="note" placeholder="why are you saving this? (optional)" size="50"><br><br>'
|
||||||
f'<input name="tags" placeholder="tags (comma-separated, e.g. solarpunk, mesh)" size="50"><br>'
|
f'<input name="tags" placeholder="tags (comma-separated, e.g. solarpunk, mesh)" size="50"><br>'
|
||||||
f'<small>tag: private to exclude from sharing</small><br><br>'
|
f'<small>tag: private to exclude from sharing</small><br><br>'
|
||||||
|
|
@ -814,6 +817,8 @@ def handle_style_form(msg=""):
|
||||||
sharing = get_setting("sharing_enabled", "0")
|
sharing = get_setting("sharing_enabled", "0")
|
||||||
checked = " checked" if sharing == "1" else ""
|
checked = " checked" if sharing == "1" else ""
|
||||||
sharing_mode = get_setting("sharing_mode", "exclude_private")
|
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 ""
|
exclude_checked = " checked" if sharing_mode != "require_public" else ""
|
||||||
require_checked = " checked" if sharing_mode == "require_public" else ""
|
require_checked = " checked" if sharing_mode == "require_public" else ""
|
||||||
shared_count = _count_shared_pages()
|
shared_count = _count_shared_pages()
|
||||||
|
|
@ -840,6 +845,17 @@ def handle_style_form(msg=""):
|
||||||
lora_txpower = get_setting("lora_txpower", "7")
|
lora_txpower = get_setting("lora_txpower", "7")
|
||||||
lora_sf = get_setting("lora_sf", "8")
|
lora_sf = get_setting("lora_sf", "8")
|
||||||
lora_cr = get_setting("lora_cr", "5")
|
lora_cr = get_setting("lora_cr", "5")
|
||||||
|
if forum_plugin is not None:
|
||||||
|
forum_section = (
|
||||||
|
f"<h2>forum</h2>"
|
||||||
|
f'<label><input type="checkbox" name="forum_enabled" value="1"{forum_checked}>'
|
||||||
|
f" enable forum (shared URL discussion board)</label><br>"
|
||||||
|
f"<small>Share URLs and discuss them with other TinyWeb instances. "
|
||||||
|
f"Requires <code>tinyweb-forum</code> — "
|
||||||
|
f'<a href="https://git.derickphan.com/lichenblankie/tinyweb-forum">more info</a>.</small><br><br>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
forum_section = ""
|
||||||
return _respond(
|
return _respond(
|
||||||
f"<h1>customize</h1>"
|
f"<h1>customize</h1>"
|
||||||
f"<h2>name your search engine</h2>"
|
f"<h2>name your search engine</h2>"
|
||||||
|
|
@ -915,6 +931,7 @@ def handle_style_form(msg=""):
|
||||||
f"<small>Saves ~50% on storage for embeddings. Slight quality reduction at large scale.</small><br><br>"
|
f"<small>Saves ~50% on storage for embeddings. Slight quality reduction at large scale.</small><br><br>"
|
||||||
f'<a href="/reindex">manage semantic index</a><br><br>'
|
f'<a href="/reindex">manage semantic index</a><br><br>'
|
||||||
f"</div>"
|
f"</div>"
|
||||||
|
{forum_section}
|
||||||
f"<h2>custom html</h2>"
|
f"<h2>custom html</h2>"
|
||||||
f"<p>Edit the full page template. Use <code>{esc('{{content}}')}</code> "
|
f"<p>Edit the full page template. Use <code>{esc('{{content}}')}</code> "
|
||||||
f"where page content should appear.</p>"
|
f"where page content should appear.</p>"
|
||||||
|
|
@ -974,6 +991,27 @@ def handle_style_submit(body):
|
||||||
set_setting("lora_txpower", body.get("lora_txpower", ["7"])[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_sf", body.get("lora_sf", ["8"])[0].strip())
|
||||||
set_setting("lora_cr", body.get("lora_cr", ["5"])[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.")
|
return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1654,7 +1692,11 @@ def _dispatch_inner(data):
|
||||||
return handle_search(query)
|
return handle_search(query)
|
||||||
elif path == "/add":
|
elif path == "/add":
|
||||||
action_type = query.get("type", ["index"])[0]
|
action_type = query.get("type", ["index"])[0]
|
||||||
return handle_add_form(action_type=action_type if action_type == "subscribe" else "index")
|
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":
|
elif path == "/pages":
|
||||||
return handle_pages(query)
|
return handle_pages(query)
|
||||||
elif path.startswith("/edit/"):
|
elif path.startswith("/edit/"):
|
||||||
|
|
@ -1689,7 +1731,15 @@ def _dispatch_inner(data):
|
||||||
elif path.startswith("/subscriptions/browse/"):
|
elif path.startswith("/subscriptions/browse/"):
|
||||||
sid = extract_id("/subscriptions/browse/")
|
sid = extract_id("/subscriptions/browse/")
|
||||||
return handle_subscription_browse(sid) if sid is not None else _error(400)
|
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":
|
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):
|
if not _check_csrf(body):
|
||||||
return _respond("<h1>403 Forbidden</h1><p>Invalid or missing CSRF token.</p>", status=403)
|
return _respond("<h1>403 Forbidden</h1><p>Invalid or missing CSRF token.</p>", status=403)
|
||||||
if path == "/add":
|
if path == "/add":
|
||||||
|
|
@ -1737,7 +1787,28 @@ def _dispatch_inner(data):
|
||||||
|
|
||||||
|
|
||||||
def dispatch_request(data):
|
def dispatch_request(data):
|
||||||
|
path = data.get("path", "/")
|
||||||
cookies = data.get("cookies", {})
|
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", "")
|
csrf_token = cookies.get("_csrf", "")
|
||||||
if not csrf_token:
|
if not csrf_token:
|
||||||
csrf_token = secrets.token_hex(32)
|
csrf_token = secrets.token_hex(32)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
# forum: pip install tinyweb-forum (optional, adds URL discussion board)
|
||||||
requests
|
requests
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
rns
|
rns
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
import html
|
import html
|
||||||
from db import get_setting
|
from db import get_setting
|
||||||
|
|
||||||
|
FORUM_ENABLED = False
|
||||||
|
|
||||||
def esc(s):
|
def esc(s):
|
||||||
return html.escape(str(s))
|
return html.escape(str(s))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TEMPLATE = "<html>\n<head>\n<meta name=\"referrer\" content=\"no-referrer\">\n<meta http-equiv=\"x-dns-prefetch-control\" content=\"off\">\n</head>\n<body>\n{{content}}\n</body>\n</html>"
|
DEFAULT_TEMPLATE = "<html>\n<head>\n<meta name=\"referrer\" content=\"no-referrer\">\n<meta http-equiv=\"x-dns-prefetch-control\" content=\"off\">\n</head>\n<body>\n{{content}}\n</body>\n</html>"
|
||||||
|
|
||||||
|
|
||||||
def _default_template():
|
def _default_template():
|
||||||
name = esc(get_setting("site_name", "tinyweb"))
|
name = esc(get_setting("site_name", "tinyweb"))
|
||||||
|
forum_link = ' | <a href="/forum">forum</a>' if FORUM_ENABLED else ""
|
||||||
return (
|
return (
|
||||||
'<html>\n<head>\n<meta name="referrer" content="no-referrer">\n<meta http-equiv="x-dns-prefetch-control" content="off">\n</head>\n<body>\n'
|
'<html>\n<head>\n<meta name="referrer" content="no-referrer">\n<meta http-equiv="x-dns-prefetch-control" content="off">\n</head>\n<body>\n'
|
||||||
f'<p><b><a href="/">{name}</a></b>'
|
f'<p><b><a href="/">{name}</a></b>'
|
||||||
' | <a href="/">search</a> | <a href="/pages">browse</a>'
|
' | <a href="/">search</a> | <a href="/pages">browse</a>'
|
||||||
' | <a href="/tags">tags</a> | <a href="/subscriptions">subscriptions</a>'
|
' | <a href="/tags">tags</a> | <a href="/subscriptions">subscriptions</a>'
|
||||||
|
f'{forum_link}'
|
||||||
' | <a href="/style">customize</a> | <a href="/about">about</a></p>\n'
|
' | <a href="/style">customize</a> | <a href="/about">about</a></p>\n'
|
||||||
"<hr>\n{{content}}\n</body>\n</html>"
|
"<hr>\n{{content}}\n</body>\n</html>"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue