Compare commits

...

No commits in common. "v0.1.0" and "main" have entirely different histories.
v0.1.0 ... main

11 changed files with 484 additions and 62 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

@ -1,43 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is TinyWeb
A personal, decentralized search engine built on the Reticulum mesh network. Users curate and search their own index of web pages, share collections over an encrypted mesh, and subscribe to friends' indexes. No algorithms, no tracking.
## Running
```bash
pip install -r requirements.txt
python app.py # Starts RNS server + HTTP gateway on 0.0.0.0:8080
python gateway.py <hash> # Run as HTTP gateway to a remote TinyWeb instance
```
There are no tests, linter, or build step.
## Architecture
Three entry points form a pipeline:
- **app.py** — Boots Reticulum, loads/creates identity from `tinyweb_identity`, announces on mesh, starts HTTP gateway as a daemon thread, then loops handling RNS requests.
- **gateway.py**`BaseHTTPRequestHandler` that translates HTTP GET/POST into a request dict and dispatches it. When `local_dispatch` is set (the default when launched from app.py), it calls handlers directly; otherwise it sends requests over a Reticulum link.
- **handlers.py** — Central router (`handle_request`) that pattern-matches the path and calls the appropriate handler. Every handler returns `{"status", "content_type", "body", "headers"}`.
## Database (db.py → index.db)
SQLite with FTS5. Schema is initialized and migrated in `init_db()` on every startup.
Key tables: `pages` (indexed URLs), `links` (extracted same-domain links), `tags`/`page_tags` (many-to-many tagging), `pages_fts` (full-text search via triggers), `subscriptions` (remote instances), `remote_pages`/`remote_pages_fts` (synced content).
`get_db()` opens a fresh connection each call — no connection pooling.
## Patterns to follow
- All HTML output is built as inline strings in handlers.py; there is no template engine. Use `templates.wrap_page(title, body_html)` to wrap content with boilerplate and custom CSS.
- Use `esc()` (html.escape) for all user-supplied content rendered in HTML.
- Handlers receive `(path_segment, ...)` args extracted by the router and return a response dict.
- Tags are stored in a join table; orphaned rows in `tags` can accumulate — always query through `page_tags` for accurate counts.
- Link extraction (`extract_links`) only follows same-domain URLs and skips binary file extensions and Wikipedia special pages.
- URL cleanup: fragments are stripped, tracking params (utm_*, fbclid, gclid, etc.) are removed before storing.
- Settings are stored as key-value pairs in the `settings` table; access via `get_setting(key, default)`.

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>