Compare commits

..

16 commits

Author SHA1 Message Date
lichenblankie
b6468a17d1 Phase 8: merge forum branch — forum plugin, themes, README 2026-06-05 04:49:37 +00:00
lichenblankie
bf4cef47e8 kodama2: add input:not([type]) for unstyled forum inputs 2026-06-05 04:35:24 +00:00
lichenblankie
0399e62c0f fix moderation inputs: add input:not([type]) selector for unstyled form fields 2026-06-05 04:34:42 +00:00
lichenblankie
5fda14cb8f kodama2 theme: add forum-action styling (FORUM_CSS no longer provides colors) 2026-06-05 04:33:49 +00:00
lichenblankie
8f1d07a768 add fade-in transition to content 2026-06-05 04:29:39 +00:00
lichenblankie
158be950d2 tinyweb-site theme: add forum-action and forum-form input styling 2026-06-05 04:28:07 +00:00
lichenblankie
37bdd49690 add tinyweb-site theme: clean, light, minimal 2026-06-05 04:26:22 +00:00
lichenblankie
17ba5700a7 add {{site_name}} placeholder to wrap_page and kodama2 theme 2026-06-05 04:17:02 +00:00
lichenblankie
49654a1fe3 hide forum link from template when forum disabled: {{forum_link}} placeholder + DB migration 2026-06-05 02:29:18 +00:00
lichenblankie
712b18ff89 add forum security caveat link to Security section 2026-06-05 02:18:54 +00:00
lichenblankie
81d0d7c9aa update forum section README: manual sync by default, gossip fanout, auto-block threshold 2026-06-05 02:16:50 +00:00
lichenblankie
cc7fde6798 forum README: gossip scaling note 2026-06-05 01:58:39 +00:00
lichenblankie
e29a905506 forum README: auto-discovery toggle and auto-prune 2026-06-05 01:00:57 +00:00
lichenblankie
681467260d forum README: auto-discovery note 2026-06-05 00:55:34 +00:00
lichenblankie
b7aa1f69e8 forum plugin integration: dispatch, style toggle, nav link, URL prefill, SO_REUSEADDR 2026-06-05 00:32:29 +00:00
lichenblankie
401f775b14 Distribute via git clone + docker compose, not registry pull 2026-06-03 04:39:58 +00:00
10 changed files with 484 additions and 19 deletions

11
.runner Normal file
View 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"
]
}

View file

@ -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
View file

@ -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
View file

@ -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")

View file

@ -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)

View file

@ -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
View 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

View file

@ -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)

View file

@ -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
View 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 &middot; 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>