Phase 8: merge forum branch — forum plugin, themes, README
This commit is contained in:
commit
b6468a17d1
10 changed files with 484 additions and 19 deletions
11
.runner
Normal file
11
.runner
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"WARNING": "This file is automatically generated by forgejo-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
|
||||||
|
"id": 14,
|
||||||
|
"uuid": "2f3252bd-5f69-4d3c-8f77-8b54caf784af",
|
||||||
|
"name": "nixos-runner",
|
||||||
|
"token": "993ebcbb295c7c58ac35fd5c2ec07cc752c99152",
|
||||||
|
"address": "https://git.derickphan.com",
|
||||||
|
"labels": [
|
||||||
|
"ubuntu-latest:docker"
|
||||||
|
]
|
||||||
|
}
|
||||||
65
README.md
65
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
|
||||||
|
|
||||||
|
|
@ -50,34 +51,37 @@ Run the downloaded file — no installation required.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Pull and run TinyWeb from the container registry:
|
TinyWeb is distributed as source. Clone the repo, then build and run with Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 8080:8080 registry.derickphan.com/tinyweb:latest
|
git clone https://git.derickphan.com/lichenblankie/tinyweb.git
|
||||||
|
cd tinyweb
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with a specific version:
|
The bundled `docker-compose.yml` builds the image from source and persists your data in a named volume:
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 8080:8080 registry.derickphan.com/tinyweb:v0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
tinyweb:
|
tinyweb:
|
||||||
image: registry.derickphan.com/tinyweb:latest
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- tinyweb-data:/data
|
- tinyweb-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tinyweb-data:
|
tinyweb-data:
|
||||||
```
|
```
|
||||||
|
|
||||||
Run with `docker compose up -d`.
|
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
|
||||||
|
|
||||||
|
|
@ -104,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 |
|
||||||
|
|
||||||
|
|
@ -115,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.
|
||||||
|
|
||||||
|
|
@ -122,13 +128,18 @@ The `/export` page produces a JSON dump of your pages. It's a migration aid —
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
Data is stored in the `/data` volume inside the container. Use a volume mount to persist data:
|
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
|
```bash
|
||||||
docker run -p 8080:8080 -v tinyweb-data:/data registry.derickphan.com/tinyweb:latest
|
docker compose exec tinyweb ls -la /data
|
||||||
|
docker compose down # stop without removing the volume
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with docker-compose (see above) — data persists in the named volume.
|
To reset everything (destroys your index and identity — back up first):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
### Command line options
|
### Command line options
|
||||||
|
|
||||||
|
|
@ -168,6 +179,31 @@ 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 are discovered automatically via mesh announces — no manual setup needed
|
||||||
|
- Sync is manual by default: click "sync now" on the forum page. Auto-sync every 5 minutes is optional (toggle on moderation page)
|
||||||
|
- At scale, sync uses epidemic gossip: 20 random peers per cycle, converging globally within ~O(log N) cycles
|
||||||
|
- Authors are identified by a short pseudonymous identity hash (no accounts, no sign-up)
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
For full feature docs, see the [tinyweb-forum README](https://git.derickphan.com/lichenblankie/tinyweb-forum).
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -193,6 +229,7 @@ Other hardening measures:
|
||||||
- **XSS escaping** — All user-supplied content is HTML-escaped before rendering
|
- **XSS escaping** — All user-supplied content is HTML-escaped before rendering
|
||||||
- **Bookmark authentication** — The bookmarklet endpoint requires a secret token
|
- **Bookmark authentication** — The bookmarklet endpoint requires a secret token
|
||||||
- **Identity file protection** — The Reticulum identity key is restricted to owner-only permissions (0600)
|
- **Identity file protection** — The Reticulum identity key is restricted to owner-only permissions (0600)
|
||||||
|
- **Forum caveats** — See [tinyweb-forum Security](https://git.derickphan.com/lichenblankie/tinyweb-forum#security) for forum-specific risks (voluntary retractions, block gossip manipulation, no rate limiting)
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
|
|
|
||||||
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()
|
||||||
|
|
|
||||||
18
db.py
18
db.py
|
|
@ -291,6 +291,24 @@ def init_db():
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_page_tags_page ON page_tags(page_id)")
|
db.execute("CREATE INDEX IF NOT EXISTS idx_page_tags_page ON page_tags(page_id)")
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_page_tags_tag ON page_tags(tag_id)")
|
db.execute("CREATE INDEX IF NOT EXISTS idx_page_tags_tag ON page_tags(tag_id)")
|
||||||
|
|
||||||
|
# Migrate custom_template: replace hardcoded forum link with {{forum_link}} placeholder
|
||||||
|
cur = db.execute("SELECT value FROM settings WHERE key='custom_template'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
updated = row[0].replace('<a href="/forum">forum</a>', "{{forum_link}}")
|
||||||
|
if updated != row[0]:
|
||||||
|
db.execute("UPDATE settings SET value=? WHERE key='custom_template'", (updated,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Migrate custom_template: replace hardcoded site name with {{site_name}} placeholder
|
||||||
|
cur = db.execute("SELECT value FROM settings WHERE key='custom_template'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and '{{site_name}}' not in row[0]:
|
||||||
|
updated = row[0].replace('href="/">tinyweb</a>', 'href="/">{{site_name}}</a>')
|
||||||
|
if updated != row[0]:
|
||||||
|
db.execute("UPDATE settings SET value=? WHERE key='custom_template'", (updated,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
db.execute("PRAGMA synchronous=NORMAL")
|
db.execute("PRAGMA synchronous=NORMAL")
|
||||||
db.execute("PRAGMA cache_size=-64000")
|
db.execute("PRAGMA cache_size=-64000")
|
||||||
|
|
|
||||||
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>"
|
||||||
|
f"{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
|
||||||
|
|
|
||||||
2
start.sh
Executable file
2
start.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/sh
|
||||||
|
exec /nix/store/vhgmnrmvvfdiw0kc2xz8px7rvg60lszc-python3-3.13.12-env/bin/python /home/lichenblankie/apps/tinyweb/app.py --bind 0.0.0.0
|
||||||
|
|
@ -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>"
|
||||||
)
|
)
|
||||||
|
|
@ -29,4 +31,7 @@ def wrap_page(body_html, use_default=False):
|
||||||
template = get_setting("custom_template") or _default_template()
|
template = get_setting("custom_template") or _default_template()
|
||||||
if "{{content}}" not in template:
|
if "{{content}}" not in template:
|
||||||
template = _default_template()
|
template = _default_template()
|
||||||
|
forum_link = ' <a href="/forum">forum</a>' if FORUM_ENABLED else ""
|
||||||
|
template = template.replace("{{forum_link}}", forum_link)
|
||||||
|
template = template.replace("{{site_name}}", esc(get_setting("site_name", "tinyweb")))
|
||||||
return template.replace("{{content}}", body_html)
|
return template.replace("{{content}}", body_html)
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="url"],
|
input[type="url"],
|
||||||
|
input:not([type]),
|
||||||
input[name="q"],
|
input[name="q"],
|
||||||
input[name="url"],
|
input[name="url"],
|
||||||
input[name="note"],
|
input[name="note"],
|
||||||
|
|
@ -328,6 +329,12 @@
|
||||||
|
|
||||||
label { color: #5a7880; }
|
label { color: #5a7880; }
|
||||||
input[type="checkbox"] { accent-color: #3a6858; }
|
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; }
|
hr { border: none; border-top: 1px solid rgba(30, 55, 50, 0.3); margin: 1rem 0; }
|
||||||
|
|
||||||
small {
|
small {
|
||||||
|
|
@ -368,11 +375,12 @@
|
||||||
<canvas id="trail"></canvas>
|
<canvas id="trail"></canvas>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<nav>
|
<nav>
|
||||||
<a class="site" href="/">tinyweb</a>
|
<a class="site" href="/">{{site_name}}</a>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a href="/pages">browse</a>
|
<a href="/pages">browse</a>
|
||||||
<a href="/tags">tags</a>
|
<a href="/tags">tags</a>
|
||||||
<a href="/subscriptions">network</a>
|
<a href="/subscriptions">network</a>
|
||||||
|
{{forum_link}}
|
||||||
<a href="/style">customize</a>
|
<a href="/style">customize</a>
|
||||||
<a href="/about">about</a>
|
<a href="/about">about</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
292
themes/tinyweb-site.html
Normal file
292
themes/tinyweb-site.html
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<meta http-equiv="x-dns-prefetch-control" content="off">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { min-height: 100vh; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #444;
|
||||||
|
background: #fff;
|
||||||
|
padding: 60px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
a { color: #444; text-decoration: none; cursor: pointer; }
|
||||||
|
a:hover { color: #222; }
|
||||||
|
input, textarea {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="search"],
|
||||||
|
input:not([type]),
|
||||||
|
textarea, select {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
|
button, input[type="submit"] {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #fff;
|
||||||
|
color: #444;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
button:hover, input[type="submit"]:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 650px;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
nav .site {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #222;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
nav .site:hover { color: #222; }
|
||||||
|
nav .links { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
nav .links a {
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background: #fff;
|
||||||
|
color: #444;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
nav .links a:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.content { width: 100%; animation: fadeIn 0.3s ease; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
h1 a { color: #222; text-decoration: none; }
|
||||||
|
h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #222;
|
||||||
|
margin: 24px 0 12px;
|
||||||
|
}
|
||||||
|
p { margin: 12px 0; color: #444; }
|
||||||
|
a { color: #444; text-decoration: none; border-bottom: 1px solid #ddd; }
|
||||||
|
a:hover { color: #222; border-bottom-color: #999; }
|
||||||
|
em { color: #666; }
|
||||||
|
.result {
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.result:last-child { border-bottom: none; }
|
||||||
|
.result > a:first-child {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #222;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.result > a:first-child:hover { color: #222; }
|
||||||
|
.note { margin-top: 4px; font-size: 15px; color: #666; }
|
||||||
|
.meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
margin: 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pagination a {
|
||||||
|
color: #444;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
.pagination a:hover { color: #222; }
|
||||||
|
.success {
|
||||||
|
color: #444;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.tags { margin-top: 4px; }
|
||||||
|
.tag, .tags a {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-right: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tag:hover, .tags a:hover {
|
||||||
|
color: #444;
|
||||||
|
border-color: #ccc;
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
margin: 16px 0;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
summary { font-size: 15px; color: #666; cursor: pointer; }
|
||||||
|
summary:hover { color: #444; }
|
||||||
|
details ul { margin-top: 8px; padding-left: 20px; }
|
||||||
|
details li { margin: 6px 0; font-size: 15px; }
|
||||||
|
ul, ol { padding-left: 20px; margin: 8px 0; }
|
||||||
|
li { margin: 6px 0; color: #444; }
|
||||||
|
li a { border-bottom: none; }
|
||||||
|
pre {
|
||||||
|
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: #444;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
label { color: #666; }
|
||||||
|
input[type="checkbox"] { accent-color: #444; }
|
||||||
|
.forum-form input, .forum-form button, .forum-toolbar input { border-radius: 0; }
|
||||||
|
.forum-actions a, a.forum-action, a.forum-action-inline {
|
||||||
|
border: 1px solid #ccc; padding: 6px 14px; text-transform: uppercase; font-size: 13px;
|
||||||
|
}
|
||||||
|
.forum-actions a:hover, a.forum-action:hover, a.forum-action-inline:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
a.forum-action-inline { text-transform: none; font-size: 13px; padding: 2px 6px; border: none; }
|
||||||
|
hr { border: none; border-top: 1px solid #eee; margin: 16px 0; }
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
footer .clock {
|
||||||
|
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
::selection { background: #222; color: #fff; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body { padding: 30px 20px; font-size: 16px; }
|
||||||
|
h1 { font-size: 26px; }
|
||||||
|
nav { flex-direction: column; align-items: flex-start; }
|
||||||
|
nav .links { gap: 6px; }
|
||||||
|
nav .links a { padding: 5px 10px; font-size: 12px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<nav>
|
||||||
|
<a class="site" href="/">{{site_name}}</a>
|
||||||
|
<div class="links">
|
||||||
|
<a href="/pages">browse</a>
|
||||||
|
<a href="/tags">tags</a>
|
||||||
|
<a href="/subscriptions">network</a>
|
||||||
|
{{forum_link}}
|
||||||
|
<a href="/style">customize</a>
|
||||||
|
<a href="/about">about</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="content">
|
||||||
|
{{content}}
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<div>curated by hand · shared over mesh</div>
|
||||||
|
<div class="clock" id="clock"></div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var d = document.getElementById('clock');
|
||||||
|
if (d) {
|
||||||
|
function tick() {
|
||||||
|
var n = new Date();
|
||||||
|
d.textContent = n.toLocaleString();
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue