Compare commits

...

23 commits

Author SHA1 Message Date
lichenblankie
3c6c941ad2 Phase 1: merge dev branch — foundation (CLAUDE.md, Docker, themes, security, WAL mode, pagination, template editor) 2026-06-05 04:47:52 +00:00
Derick Phan
2df92752b6
Normalize template line endings before storing to fix navbar disappearing
The previous fix only normalized \r\n for comparison but stored the raw
template with browser line endings. Now all \r\n and \r are converted to
\n before both comparing and storing, preventing the bare skeleton from
ever being saved as a custom template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 22:29:45 -07:00
Derick Phan
6070e09834
Fix navbar disappearing when saving customize form
Browser textarea submissions convert \n to \r\n, causing the template
comparison against DEFAULT_TEMPLATE to always fail. This saved the bare
skeleton as a custom template, overriding the default navbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 22:00:27 -07:00
Derick Phan
7c225145f0
Redesign subscriptions page with card layout
Replace cramped table layout with card-based design that works
better in narrow viewports and across different themes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 21:51:58 -07:00
Derick Phan
ffdfb821c8
Set share_instance = No for reliable mesh announces
With share_instance = Yes, announces weren't being sent over TCP
in Docker environments. Setting it to No ensures each TinyWeb
instance manages its own Reticulum interfaces directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 21:39:37 -07:00
Derick Phan
fe0e15edc4
Add delay before announce to ensure TCP interface is ready
The announce was firing before the TCP transport connection was fully
established, causing Docker instances to never announce over the mesh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 21:32:11 -07:00
Derick Phan
2adef21ec6
Add default internet transport node for zero-config mesh connectivity
New TinyWeb instances now auto-connect to reticulum.derickphan.com:4242
so users get internet mesh connectivity out of the box without any
manual Reticulum configuration. Env var overrides still supported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 20:13:35 -07:00
Derick Phan
b49b1df8b5
Set PYTHONUNBUFFERED=1 in Dockerfile for real-time log output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 19:23:03 -07:00
Derick Phan
5f8863ce77
Add entrypoint script for configurable Reticulum networking in Docker
Replaces static CMD with an entrypoint that generates RNS config from
environment variables (RNS_TCP_HOST/PORT), enabling TCP transport for
environments without LAN auto-discovery (e.g. Docker on macOS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 18:44:26 -07:00
Derick Phan
ddaba43710
Add Dockerfile and Docker Compose for one-command setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 17:59:08 -07:00
Derick Phan
fede0287e9
Change pagination from 50 to 10 results per page
Better fit for a curated personal search engine — keeps pages
focused and renders faster over low-bandwidth mesh links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 12:03:03 -07:00
Derick Phan
f2e8dd042a
Add WAL mode, connection pooling, pagination, and delta sync
WAL + pooling:
- Enable WAL journal mode for concurrent read/write support
- Add connection pool (size 4) with return_db() to reuse connections
  instead of opening/closing on every request

Pagination:
- Search results, /pages, and /tags/<name> now paginate at 50 per page
- Prev/next navigation links appear when results exceed one page

Delta sync:
- Pages table gains last_modified timestamp, set on insert/update
- /api/sites accepts ?since= param to return only changed pages
- Subscription sync uses last_sync timestamp for incremental fetches
- Remote pages upserted instead of delete-all/re-insert
- Full sync includes all_urls list for detecting remote deletions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 12:00:43 -07:00
Derick Phan
6981d39ddd
Normalize URLs to prevent duplicate indexing
clean_url() now canonicalizes: http→https, strips www., removes
trailing slashes, drops default ports, and sorts query params.
Prevents the same page from being indexed multiple times under
different URL variations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:34:15 -07:00
Derick Phan
d2cb0d00bc
Add README with setup, usage, architecture, and security docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:29:14 -07:00
Derick Phan
86e4c1f151
Fix index_url using wrong page_id after upsert
lastrowid returns 0 when ON CONFLICT DO UPDATE fires on an existing
row, causing links to not be cleaned up or associated correctly on
re-index. Now fetches the actual row ID with a SELECT after upsert.
Also adds try/finally for connection safety.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:24:01 -07:00
Derick Phan
c10aa7955c
Fix SSRF redirect bypass, identity permissions, error leakage, and DB connection leaks
- SSRF: disable automatic redirects, manually follow up to 5 hops with
  IP re-validation at each step to prevent redirect-to-localhost bypass
- Identity file: enforce 0600 permissions on tinyweb_identity at load
  and creation to prevent other users from reading the private key
- Error messages: replace raw exception strings with generic messages
  to avoid leaking internal paths/hostnames to the UI
- DB connections: wrap all get_db() usage in try/finally to guarantee
  close() even when handlers throw mid-operation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:18:47 -07:00
Derick Phan
d5f2d01651
Harden security: bookmark auth, CSP headers, per-session CSRF, and more
- Bookmark endpoint now requires a secret token (stored in settings)
- Style reset moved from GET to POST with CSRF protection
- Open redirect prevention in _redirect() helper
- Import capped at 100 URLs to prevent abuse
- page_tags cleaned up on delete + PRAGMA foreign_keys enabled
- CSP, X-Frame-Options, X-Content-Type-Options on all responses
- CSRF tokens now per-session via double-submit cookie pattern
- Tag names URL-decoded for special characters
- Gateway forwards cookies in request data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:10:37 -07:00
Derick Phan
9ddecf71db
Add security hardening: CSRF, SSRF, FTS5, and DELETE via POST
- CSRF: Generate random token at startup, include as hidden field in
  all 11 POST forms, validate at top of POST dispatch (returns 403)
- SSRF: Block private/internal IP ranges (127/8, 10/8, 172.16/12,
  192.168/16, 169.254/16, ::1, fc00::/7) by resolving hostname before
  fetch. Remove verify=False from requests.get().
- DELETE: Change /delete/<id> from GET (instant delete) to GET
  (confirmation page) + POST (actual delete) to prevent accidental
  deletion from prefetchers/crawlers.
- FTS5: Wrap search input in double quotes to neutralize FTS5
  operators (AND, OR, NOT, *, column:). Add try/except fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:54:22 -07:00
Derick Phan
9c4ed9ac9e
Add themes folder with kodama template and gitignore index.db
Save the custom kodama template to themes/kodama.html so it's
version-controlled as a file rather than only living in the database.
Stop tracking index.db since it's runtime data, not source code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:11:32 -07:00
Derick Phan
862a383101
Add kodama tree spirit overlay and clean up orphaned remote pages
Add animated kodama (tree spirits from Princess Mononoke) to the
custom template as a canvas overlay. Each spirit has unique organic
proportions: rock-like blob head shapes, varied eye spacing/size,
optional mouths and arms, and a soft luminous glow. They fade in/out,
bob gently, and occasionally rattle their heads.

Also removed 3 orphaned remote_pages rows from deleted subscriptions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:08:17 -07:00
Derick Phan
b17988fc95
Fix custom template rendering and ensure customize page uses default layout
Add use_default parameter to wrap_page/respond so the customize page
always renders with the default template (preventing a broken custom
template from locking out the editor). Also fix the stored custom
template: add <!DOCTYPE html> to prevent quirks mode and remove
newlines inside CSS cursor data URIs that caused CSS parse errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:45:42 -07:00
Derick Phan
8741c2fffb
Add custom HTML template editor and clean up UI
- Replace CSS-only customization with full HTML template editing
- Users edit the entire page wrapper with {{content}} placeholder
- Add /style?reset escape hatch to recover from broken templates
- Move nav links to template, remove redundant nav from search page
- Delete remote pages when unsubscribing from an instance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:04:23 -07:00
Derick Phan
4df0ef03f5
Add CLAUDE.md with project architecture and conventions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 08:17:38 -07:00
14 changed files with 1692 additions and 352 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
__pycache__/
index.db*
tinyweb_identity
.git/
*.md

43
CLAUDE.md Normal file
View file

@ -0,0 +1,43 @@
# 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)`.

18
Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /data \
&& ln -sf /data/index.db index.db \
&& ln -sf /data/tinyweb_identity tinyweb_identity
ENV PYTHONUNBUFFERED=1
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]

View file

@ -0,0 +1,75 @@
# TinyWeb
A personal, decentralized search engine built on the [Reticulum](https://reticulum.network/) mesh network. Curate your own index of web pages, search it locally, and share collections with friends over an encrypted mesh. No algorithms, no ads, no tracking.
## Features
- **Personal search index** — Save pages you find valuable, search them with full-text search (SQLite FTS5)
- **Tagging** — Organize saved pages with comma-separated tags
- **Bookmarklet** — One-click indexing from any browser tab
- **Subscriptions** — Subscribe to friends' TinyWeb instances over Reticulum and search their indexes alongside yours
- **Custom templates** — Full HTML/CSS/JS template editor to personalize your instance
- **Import/export** — JSON-based backup and restore
- **Mesh-native** — Works over Reticulum without the internet; encrypted and decentralized by default
## Getting started
```bash
pip install -r requirements.txt
python app.py
```
This starts the Reticulum server and an HTTP gateway on `http://localhost:8080`. Open it in your browser.
Your destination hash is printed on startup — share it with friends so they can subscribe to your index.
## Remote gateway
To browse a remote TinyWeb instance without running your own index:
```bash
python gateway.py <destination_hash>
```
This connects over Reticulum and serves the remote instance at `http://localhost:8080`.
## How it works
1. **Save pages** — Use the `/add` form or the bookmarklet (found on `/style`) to index any URL
2. **Search** — Full-text search across your saved pages, linked pages from trusted sites, and synced subscriptions
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`
## Project structure
```
app.py — Entry point: boots Reticulum, starts HTTP gateway
gateway.py — HTTP-to-RNS bridge (local or remote dispatch)
handlers.py — Route dispatcher and all request handlers
db.py — SQLite database, FTS5, URL fetching, SSRF protection
templates.py — HTML template rendering and escaping
rns_client.py — Reticulum client for fetching remote site lists
themes/ — Saved HTML templates (e.g. kodama.html)
```
## Security
TinyWeb includes several hardening measures:
- **CSRF protection** — All POST forms use per-session tokens via double-submit cookies
- **SSRF prevention** — URL fetching validates hostnames against private IP ranges, with redirect re-validation
- **FTS5 injection prevention** — Search queries are sanitized before passing to SQLite MATCH
- **Content Security Policy** — CSP headers on all HTML responses restrict script/style/frame sources
- **XSS escaping** — All user-supplied content is HTML-escaped before rendering
- **Bookmark authentication** — The bookmarklet endpoint requires a secret token
- **Identity file protection** — The Reticulum identity key is restricted to owner-only permissions (0600)
## Dependencies
- [requests](https://docs.python-requests.org/) — HTTP fetching
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — HTML parsing and link extraction
- [rns](https://reticulum.network/) — Reticulum mesh networking
## Philosophy
TinyWeb is built for the slow web — intentionality over speed, human curation over algorithmic feeds, privacy over surveillance, and community over corporations. Every page in your index was saved because you found it valuable, not because an algorithm told you to click.

43
app.py
View file

@ -11,13 +11,20 @@ from gateway import GatewayState, GatewayHandler, GATEWAY_PORT
APP_NAME = "tinyweb" APP_NAME = "tinyweb"
ASPECTS = ["server"] ASPECTS = ["server"]
IDENTITY_FILE = "tinyweb_identity" IDENTITY_FILE = "tinyweb_identity"
DEFAULT_TRANSPORT_HOST = "reticulum.derickphan.com"
DEFAULT_TRANSPORT_PORT = 4242
def load_or_create_identity(): def load_or_create_identity():
if os.path.isfile(IDENTITY_FILE): if os.path.isfile(IDENTITY_FILE):
# Ensure identity file is only readable by owner
current = os.stat(IDENTITY_FILE).st_mode & 0o777
if current != 0o600:
os.chmod(IDENTITY_FILE, 0o600)
return RNS.Identity.from_file(IDENTITY_FILE) return RNS.Identity.from_file(IDENTITY_FILE)
identity = RNS.Identity() identity = RNS.Identity()
identity.to_file(IDENTITY_FILE) identity.to_file(IDENTITY_FILE)
os.chmod(IDENTITY_FILE, 0o600)
return identity return identity
@ -35,9 +42,41 @@ def start_gateway(reticulum):
thread.start() thread.start()
def ensure_rns_config(config_dir):
"""Generate a default Reticulum config with internet transport if none exists."""
if config_dir is None:
config_dir = os.path.expanduser("~/.reticulum")
config_file = os.path.join(config_dir, "config")
if os.path.exists(config_file):
return
os.makedirs(config_dir, exist_ok=True)
with open(config_file, "w") as f:
f.write(f"""[reticulum]
enable_transport = False
share_instance = No
[logging]
loglevel = 4
[interfaces]
[[Default Interface]]
type = AutoInterface
enabled = Yes
[[TCP Transport]]
type = TCPClientInterface
enabled = yes
target_host = {DEFAULT_TRANSPORT_HOST}
target_port = {DEFAULT_TRANSPORT_PORT}
""")
print(f"Created Reticulum config at {config_file}")
def main(): def main():
init_db() init_db()
reticulum = RNS.Reticulum() config_dir = os.environ.get("RNS_CONFIG_DIR")
ensure_rns_config(config_dir)
reticulum = RNS.Reticulum(configdir=config_dir)
identity = load_or_create_identity() identity = load_or_create_identity()
destination = RNS.Destination( destination = RNS.Destination(
@ -54,6 +93,8 @@ def main():
allow=RNS.Destination.ALLOW_ALL, allow=RNS.Destination.ALLOW_ALL,
) )
# Brief delay to ensure all interfaces (especially TCP) are fully ready
time.sleep(2)
destination.announce() destination.announce()
set_setting("dest_hash", destination.hash.hex()) set_setting("dest_hash", destination.hash.hex())
start_gateway(reticulum) start_gateway(reticulum)

137
db.py
View file

@ -1,10 +1,42 @@
import socket
import ipaddress
import sqlite3 import sqlite3
import requests import requests
from urllib.parse import urlparse, urljoin, parse_qs, urlencode, urlunparse from urllib.parse import urlparse, urljoin, parse_qs, urlencode, urlunparse, quote
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
DATABASE = "index.db" DATABASE = "index.db"
BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def _validate_url_target(url):
"""Resolve hostname and block private/internal IPs to prevent SSRF."""
parsed = urlparse(url)
hostname = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if not hostname:
raise ValueError(f"No hostname in URL: {url}")
try:
addrs = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for family, type_, proto, canonname, sockaddr in addrs:
ip = ipaddress.ip_address(sockaddr[0])
for network in BLOCKED_NETWORKS:
if ip in network:
raise ValueError(f"URL resolves to blocked address: {ip}")
SKIP_EXT = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".zip", ".mp3", ".mp4", ".css", ".js", ".ico", ".xml", ".json") SKIP_EXT = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".zip", ".mp3", ".mp4", ".css", ".js", ".ico", ".xml", ".json")
TRACKING_PARAMS = { TRACKING_PARAMS = {
@ -16,18 +48,64 @@ TRACKING_PARAMS = {
def clean_url(url): def clean_url(url):
parsed = urlparse(url) parsed = urlparse(url)
# Prefer https
scheme = "https" if parsed.scheme in ("http", "https") else parsed.scheme
# Normalize hostname: lowercase, strip www.
hostname = (parsed.hostname or "").lower()
if hostname.startswith("www."):
hostname = hostname[4:]
# Preserve explicit non-default ports
port = parsed.port
if port and ((scheme == "https" and port == 443) or (scheme == "http" and port == 80)):
port = None
netloc = f"{hostname}:{port}" if port else hostname
# Strip trailing slash (keep root "/" as-is)
path = parsed.path.rstrip("/") or "/"
# Remove tracking params and sort remaining for consistent ordering
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
cleaned = {k: v for k, v in params.items() if k.lower() not in TRACKING_PARAMS} cleaned = sorted(
new_query = urlencode(cleaned, doseq=True) ((k, sorted(v)) for k, v in params.items() if k.lower() not in TRACKING_PARAMS),
return urlunparse(parsed._replace(query=new_query)) key=lambda x: x[0],
)
new_query = urlencode(cleaned, doseq=True, quote_via=quote)
return urlunparse((scheme, netloc, path, "", new_query, ""))
_pool = []
_pool_lock = __import__("threading").Lock()
_POOL_SIZE = 4
def get_db(): def get_db():
db = sqlite3.connect(DATABASE) with _pool_lock:
if _pool:
db = _pool.pop()
try:
db.execute("SELECT 1")
return db
except Exception:
pass
db = sqlite3.connect(DATABASE, timeout=10)
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA foreign_keys = ON")
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
return db return db
def return_db(db):
with _pool_lock:
if len(_pool) < _POOL_SIZE:
_pool.append(db)
else:
db.close()
def init_db(): def init_db():
db = sqlite3.connect(DATABASE) db = sqlite3.connect(DATABASE)
db.execute( db.execute(
@ -36,7 +114,8 @@ def init_db():
" url TEXT UNIQUE NOT NULL," " url TEXT UNIQUE NOT NULL,"
" title TEXT," " title TEXT,"
" body TEXT," " body TEXT,"
" note TEXT DEFAULT ''" " note TEXT DEFAULT '',"
" last_modified TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))"
")" ")"
) )
db.execute( db.execute(
@ -140,26 +219,38 @@ def init_db():
db.execute("ALTER TABLE remote_pages ADD COLUMN tags TEXT DEFAULT ''") db.execute("ALTER TABLE remote_pages ADD COLUMN tags TEXT DEFAULT ''")
db.commit() db.commit()
# Migrate pages: add last_modified column if missing
page_cols = [row[1] for row in db.execute("PRAGMA table_info(pages)").fetchall()]
if "last_modified" not in page_cols:
db.execute("ALTER TABLE pages ADD COLUMN last_modified TEXT DEFAULT ''")
db.execute("UPDATE pages SET last_modified = strftime('%Y-%m-%dT%H:%M:%S','now') WHERE last_modified = ''")
db.commit()
db.execute("PRAGMA journal_mode=WAL")
db.commit() db.commit()
db.close() db.close()
def get_setting(key, default=""): def get_setting(key, default=""):
db = get_db() db = get_db()
try:
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
db.close()
return row["value"] if row else default return row["value"] if row else default
finally:
return_db(db)
def set_setting(key, value): def set_setting(key, value):
db = get_db() db = get_db()
try:
db.execute( db.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) " "INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value), (key, value),
) )
db.commit() db.commit()
db.close() finally:
return_db(db)
def get_site_name(): def get_site_name():
@ -167,7 +258,19 @@ def get_site_name():
def fetch_page(url): def fetch_page(url):
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, verify=False) _validate_url_target(url)
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, allow_redirects=False)
# Follow redirects manually, re-validating each target
max_redirects = 5
while resp.is_redirect and max_redirects > 0:
redirect_url = resp.headers.get("Location")
if not redirect_url:
break
redirect_url = urljoin(url, redirect_url)
_validate_url_target(redirect_url)
url = redirect_url
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, allow_redirects=False)
max_redirects -= 1
resp.raise_for_status() resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser") soup = BeautifulSoup(resp.text, "html.parser")
@ -204,12 +307,15 @@ def index_url(url, note=""):
url = clean_url(url) url = clean_url(url)
title, body, links = fetch_page(url) title, body, links = fetch_page(url)
db = get_db() db = get_db()
cur = db.execute( try:
"INSERT INTO pages (url, title, body, note) VALUES (?, ?, ?, ?) " now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, note=excluded.note", db.execute(
(url, title, body, note), "INSERT INTO pages (url, title, body, note, last_modified) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(url) DO UPDATE SET title=excluded.title, body=excluded.body, "
"note=excluded.note, last_modified=excluded.last_modified",
(url, title, body, note, now),
) )
page_id = cur.lastrowid page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,)) db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
for href, label in links: for href, label in links:
db.execute( db.execute(
@ -217,5 +323,6 @@ def index_url(url, note=""):
(page_id, href, label), (page_id, href, label),
) )
db.commit() db.commit()
db.close() finally:
return_db(db)
return title return title

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
services:
tinyweb:
build: .
ports:
- "8080:8080"
volumes:
- tinyweb-data:/data
restart: unless-stopped
# Connect to another Reticulum instance over TCP.
# Required on macOS (Docker can't do LAN auto-discovery).
# On Linux, auto-discovery works with network_mode: host.
# environment:
# - RNS_TCP_HOST=10.0.0.100
# - RNS_TCP_PORT=4242
volumes:
tinyweb-data:

33
entrypoint.sh Executable file
View file

@ -0,0 +1,33 @@
#!/bin/sh
# Generate Reticulum config with optional TCP peer
# Set RNS_TCP_HOST and RNS_TCP_PORT env vars to connect to a remote instance
CONFIG_DIR="/data/.reticulum"
CONFIG_FILE="$CONFIG_DIR/config"
mkdir -p "$CONFIG_DIR"
if [ ! -f "$CONFIG_FILE" ]; then
cat > "$CONFIG_FILE" <<EOF
[reticulum]
enable_transport = False
share_instance = No
[logging]
loglevel = 4
[interfaces]
[[Default Interface]]
type = AutoInterface
enabled = Yes
[[TCP Transport]]
type = TCPClientInterface
enabled = yes
target_host = ${RNS_TCP_HOST:-reticulum.derickphan.com}
target_port = ${RNS_TCP_PORT:-4242}
EOF
fi
export RNS_CONFIG_DIR="$CONFIG_DIR"
exec python app.py

View file

@ -75,11 +75,22 @@ class GatewayHandler(BaseHTTPRequestHandler):
raw = self.rfile.read(length).decode() raw = self.rfile.read(length).decode()
body = parse_qs(raw) body = parse_qs(raw)
# Parse cookies
cookies = {}
cookie_header = self.headers.get("Cookie", "")
if cookie_header:
for part in cookie_header.split(";"):
part = part.strip()
if "=" in part:
k, v = part.split("=", 1)
cookies[k.strip()] = v.strip()
request_data = { request_data = {
"method": method, "method": method,
"path": parsed.path, "path": parsed.path,
"query": query, "query": query,
"body": body, "body": body,
"cookies": cookies,
"gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"), "gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"),
} }

View file

@ -1,21 +1,58 @@
import json import json
import secrets
import threading
from datetime import datetime from datetime import datetime
from urllib.parse import unquote
from db import get_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
from templates import esc, snippet, wrap_page from templates import esc, snippet, wrap_page, DEFAULT_TEMPLATE
from rns_client import fetch_remote_sites from rns_client import fetch_remote_sites
_request_local = threading.local()
def _respond(body_html, status=200):
def _get_csrf_token():
return getattr(_request_local, 'csrf_token', '')
def _csrf_field():
return f'<input type="hidden" name="_csrf" value="{_get_csrf_token()}">'
def _check_csrf(body):
token = body.get("_csrf", [""])[0]
expected = _get_csrf_token()
if not expected or not token:
return False
return secrets.compare_digest(token, expected)
def _sanitize_fts_query(query):
"""Escape user input for safe use in FTS5 MATCH."""
escaped = query.replace('"', '""')
return f'"{escaped}"'
def _get_bookmark_token():
token = get_setting("bookmark_token")
if not token:
token = secrets.token_hex(16)
set_setting("bookmark_token", token)
return token
def _respond(body_html, status=200, use_default=False):
return { return {
"status": status, "status": status,
"content_type": "text/html; charset=utf-8", "content_type": "text/html; charset=utf-8",
"body": wrap_page(body_html), "body": wrap_page(body_html, use_default=use_default),
"headers": {}, "headers": {},
} }
def _redirect(location): def _redirect(location):
if not location.startswith("/") or location.startswith("//"):
location = "/"
return { return {
"status": 302, "status": 302,
"content_type": "text/html; charset=utf-8", "content_type": "text/html; charset=utf-8",
@ -46,6 +83,31 @@ def _error(status):
return _respond(f"<h1>{status}</h1>", status) return _respond(f"<h1>{status}</h1>", status)
PER_PAGE = 10
def _paginate(query, key="p"):
try:
page = int(query.get(key, ["1"])[0])
except (ValueError, IndexError):
page = 1
return max(1, page)
def _page_nav(page, total, base_url):
if total <= PER_PAGE:
return ""
total_pages = (total + PER_PAGE - 1) // PER_PAGE
sep = "&" if "?" in base_url else "?"
parts = []
if page > 1:
parts.append(f'<a href="{base_url}{sep}p={page - 1}">&laquo; prev</a>')
parts.append(f"page {page} of {total_pages}")
if page < total_pages:
parts.append(f'<a href="{base_url}{sep}p={page + 1}">next &raquo;</a>')
return f'<p class="pagination">{" | ".join(parts)}</p>'
# --- Tag helpers --- # --- Tag helpers ---
@ -59,7 +121,7 @@ def _get_page_tags(page_id, db=None):
"WHERE pt.page_id = ? ORDER BY t.name", (page_id,) "WHERE pt.page_id = ? ORDER BY t.name", (page_id,)
).fetchall() ).fetchall()
if close: if close:
db.close() return_db(db)
return [r["name"] for r in rows] return [r["name"] for r in rows]
@ -75,7 +137,7 @@ def _set_page_tags(page_id, tag_string, db=None):
db.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id)) db.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
if close: if close:
db.commit() db.commit()
db.close() return_db(db)
# --- Route handlers --- # --- Route handlers ---
@ -83,19 +145,30 @@ def _set_page_tags(page_id, tag_string, db=None):
def handle_search(query): def handle_search(query):
q = query.get("q", [""])[0].strip() q = query.get("q", [""])[0].strip()
page = _paginate(query)
offset = (page - 1) * PER_PAGE
db = get_db() db = get_db()
try:
count = db.execute("SELECT count(*) FROM pages").fetchone()[0] count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
name = get_site_name() name = get_site_name()
result_html = "" result_html = ""
trusted_html = "" trusted_html = ""
if q: if q:
try:
total_results = db.execute(
"SELECT count(*) FROM pages_fts WHERE pages_fts MATCH ?",
(_sanitize_fts_query(q),),
).fetchone()[0]
rows = db.execute( rows = db.execute(
"SELECT p.id, p.url, p.title, p.body, p.note " "SELECT p.id, p.url, p.title, p.body, p.note "
"FROM pages_fts f JOIN pages p ON f.rowid = p.id " "FROM pages_fts f JOIN pages p ON f.rowid = p.id "
"WHERE pages_fts MATCH ? ORDER BY rank LIMIT 50", "WHERE pages_fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?",
(q,), (_sanitize_fts_query(q), PER_PAGE, offset),
).fetchall() ).fetchall()
except Exception:
rows = []
total_results = 0
if rows: if rows:
for r in rows: for r in rows:
note_html = "" note_html = ""
@ -150,14 +223,17 @@ def handle_search(query):
) )
# search synced pages from subscriptions # search synced pages from subscriptions
try:
remote_rows = db.execute( remote_rows = db.execute(
"SELECT rp.url, rp.title, rp.note, s.name AS source_name " "SELECT rp.url, rp.title, rp.note, s.name AS source_name "
"FROM remote_pages_fts rpf " "FROM remote_pages_fts rpf "
"JOIN remote_pages rp ON rpf.rowid = rp.id " "JOIN remote_pages rp ON rpf.rowid = rp.id "
"JOIN subscriptions s ON rp.subscription_id = s.id " "JOIN subscriptions s ON rp.subscription_id = s.id "
"WHERE remote_pages_fts MATCH ? ORDER BY rank LIMIT 50", "WHERE remote_pages_fts MATCH ? ORDER BY rank LIMIT 50",
(q,), (_sanitize_fts_query(q),),
).fetchall() ).fetchall()
except Exception:
remote_rows = []
remote_html = "" remote_html = ""
if q and remote_rows: if q and remote_rows:
@ -180,25 +256,21 @@ def handle_search(query):
f'<ul>{source_items}</ul>' f'<ul>{source_items}</ul>'
f'</details>' f'</details>'
) )
finally:
db.close() return_db(db)
sub_count = "" sub_count = ""
if q and remote_rows: if q and remote_rows:
sub_count = f" + {len(remote_rows)} from subscriptions" sub_count = f" + {len(remote_rows)} from subscriptions"
return _respond( return _respond(
f'<h1><a href="/">{esc(name)}</a></h1>'
f'<form method="get" action="/">' f'<form method="get" action="/">'
f'<input name="q" value="{esc(q)}" placeholder="search your index" size="40">' f'<input name="q" value="{esc(q)}" placeholder="search your index" size="40">'
f' <button type="submit">search</button>' f' <button type="submit">search</button>'
f'</form>' f'</form>'
f'<p>{count} page(s) indexed.' f'<p class="meta">{count} pages indexed'
f' <a href="/add">+ add url</a>' f' · <a href="/add">+ add url</a></p>'
f' | <a href="/pages">browse</a>' f'{result_html}'
f' | <a href="/tags">tags</a>' f'{_page_nav(page, total_results, f"/?q={esc(q)}") if q else ""}'
f' | <a href="/subscriptions">subscriptions</a>' f'{trusted_html}{remote_html}'
f' | <a href="/style">customize</a>'
f' | <a href="/about">about</a></p>'
f'<hr>{result_html}{trusted_html}{remote_html}'
) )
@ -206,6 +278,7 @@ def handle_add_form(msg=""):
return _respond( return _respond(
f"<h1>add url</h1>" f"<h1>add url</h1>"
f'<form method="post" action="/add">' f'<form method="post" action="/add">'
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"><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><br>' f'<input name="tags" placeholder="tags (comma-separated, e.g. solarpunk, mesh)" size="50"><br><br>'
@ -228,19 +301,30 @@ def handle_add_submit(body):
title = index_url(url, note) title = index_url(url, note)
if tags: if tags:
db = get_db() db = get_db()
try:
row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone() row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
if row: if row:
_set_page_tags(row["id"], tags, db) _set_page_tags(row["id"], tags, db)
db.commit() db.commit()
db.close() finally:
return_db(db)
return handle_add_form(f'Indexed: <a href="{esc(url)}">{esc(title)}</a>') return handle_add_form(f'Indexed: <a href="{esc(url)}">{esc(title)}</a>')
except Exception as e: except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}") return handle_add_form(f"Error: {esc(str(e))}")
except Exception:
return handle_add_form("Error: could not fetch or index that URL.")
def handle_pages(): def handle_pages(query=None):
page = _paginate(query or {})
offset = (page - 1) * PER_PAGE
db = get_db() db = get_db()
rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall() try:
total = db.execute("SELECT count(*) FROM pages").fetchone()[0]
rows = db.execute(
"SELECT id, url, title, note FROM pages ORDER BY id DESC LIMIT ? OFFSET ?",
(PER_PAGE, offset),
).fetchall()
items = "" items = ""
for r in rows: for r in rows:
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else "" note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
@ -255,10 +339,12 @@ def handle_pages():
f'<a href="/edit/{r["id"]}">edit</a> ' f'<a href="/edit/{r["id"]}">edit</a> '
f'<a href="/delete/{r["id"]}">remove</a></li>' f'<a href="/delete/{r["id"]}">remove</a></li>'
) )
db.close() finally:
return_db(db)
return _respond( return _respond(
f"<h1>indexed pages ({len(rows)})</h1>" f"<h1>indexed pages ({total})</h1>"
f"<ul>{items}</ul>" f"<ul>{items}</ul>"
f'{_page_nav(page, total, "/pages")}'
f'<p><a href="/export">export</a> | <a href="/import">import</a></p>' f'<p><a href="/export">export</a> | <a href="/import">import</a></p>'
f'<a href="/">back</a>' f'<a href="/">back</a>'
) )
@ -266,17 +352,19 @@ def handle_pages():
def handle_edit_form(page_id, msg=""): def handle_edit_form(page_id, msg=""):
db = get_db() db = get_db()
try:
row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone() row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone()
if not row: if not row:
db.close()
return _error(404) return _error(404)
tags = ", ".join(_get_page_tags(page_id, db)) tags = ", ".join(_get_page_tags(page_id, db))
db.close() finally:
return_db(db)
return _respond( return _respond(
f"<h1>edit page</h1>" f"<h1>edit page</h1>"
f"<p><b>{esc(row['title'])}</b><br>" f"<p><b>{esc(row['title'])}</b><br>"
f"<small>{esc(row['url'])}</small></p>" f"<small>{esc(row['url'])}</small></p>"
f'<form method="post" action="/edit/{row["id"]}">' f'<form method="post" action="/edit/{row["id"]}">'
f'{_csrf_field()}'
f'<input name="note" value="{esc(row["note"])}" placeholder="why did you save this?" size="50"><br><br>' f'<input name="note" value="{esc(row["note"])}" placeholder="why did you save this?" size="50"><br><br>'
f'<input name="tags" value="{esc(tags)}" placeholder="tags (comma-separated)" size="50"><br><br>' f'<input name="tags" value="{esc(tags)}" placeholder="tags (comma-separated)" size="50"><br><br>'
f'<button type="submit">save</button>' f'<button type="submit">save</button>'
@ -290,23 +378,52 @@ def handle_edit_submit(page_id, body):
note = body.get("note", [""])[0].strip() note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip() tags = body.get("tags", [""])[0].strip()
db = get_db() db = get_db()
try:
db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id)) db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id))
_set_page_tags(page_id, tags, db) _set_page_tags(page_id, tags, db)
db.commit() db.commit()
db.close() finally:
return_db(db)
return _redirect("/pages") return _redirect("/pages")
def handle_delete_confirm(page_id):
db = get_db()
try:
row = db.execute("SELECT id, url, title FROM pages WHERE id = ?", (page_id,)).fetchone()
finally:
return_db(db)
if not row:
return _error(404)
return _respond(
f"<h1>confirm delete</h1>"
f"<p>Remove <b>{esc(row['title'])}</b><br>"
f"<small>{esc(row['url'])}</small></p>"
f'<form method="post" action="/delete/{row["id"]}">'
f'{_csrf_field()}'
f'<button type="submit">yes, delete</button>'
f"</form>"
f' <a href="/pages">cancel</a>'
)
def handle_delete(page_id): def handle_delete(page_id):
db = get_db() db = get_db()
try:
db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,)) db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
db.execute("DELETE FROM pages WHERE id = ?", (page_id,)) db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
db.commit() db.commit()
db.close() finally:
return_db(db)
return _redirect("/pages") return _redirect("/pages")
def handle_bookmark(query): def handle_bookmark(query):
token = query.get("token", [""])[0]
expected = _get_bookmark_token()
if not token or not secrets.compare_digest(token, expected):
return _text_response("error: invalid or missing token", status=403, headers={"Access-Control-Allow-Origin": "*"})
url = clean_url(query.get("url", [""])[0].strip()) url = clean_url(query.get("url", [""])[0].strip())
if not url or not url.startswith(("http://", "https://")): if not url or not url.startswith(("http://", "https://")):
return _text_response("error: invalid url", headers={"Access-Control-Allow-Origin": "*"}) return _text_response("error: invalid url", headers={"Access-Control-Allow-Origin": "*"})
@ -320,8 +437,10 @@ def handle_bookmark(query):
def handle_export(): def handle_export():
db = get_db() db = get_db()
try:
rows = db.execute("SELECT url, title, note FROM pages ORDER BY id").fetchall() rows = db.execute("SELECT url, title, note FROM pages ORDER BY id").fetchall()
db.close() finally:
return_db(db)
data = [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in rows] data = [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in rows]
return _json_response(data, headers={"Content-Disposition": "attachment; filename=tinyweb-export.json"}) return _json_response(data, headers={"Content-Disposition": "attachment; filename=tinyweb-export.json"})
@ -331,6 +450,7 @@ def handle_import_form(msg=""):
f"<h1>import</h1>" f"<h1>import</h1>"
f"<p>Paste the contents of a tinyweb export file (JSON).</p>" f"<p>Paste the contents of a tinyweb export file (JSON).</p>"
f'<form method="post" action="/import">' f'<form method="post" action="/import">'
f'{_csrf_field()}'
f'<textarea name="data" rows="12" cols="60" placeholder=\'[{{"url": "...", "note": "..."}}]\'></textarea><br><br>' f'<textarea name="data" rows="12" cols="60" placeholder=\'[{{"url": "...", "note": "..."}}]\'></textarea><br><br>'
f'<button type="submit">import</button>' f'<button type="submit">import</button>'
f"</form>" f"</form>"
@ -350,6 +470,10 @@ def handle_import_submit(body):
if not isinstance(data, list): if not isinstance(data, list):
return handle_import_form("Expected a JSON array.") return handle_import_form("Expected a JSON array.")
MAX_IMPORT = 100
if len(data) > MAX_IMPORT:
return handle_import_form(f"Too many entries. Maximum is {MAX_IMPORT}.")
imported = 0 imported = 0
errors = 0 errors = 0
for entry in data: for entry in data:
@ -367,7 +491,7 @@ def handle_import_submit(body):
def handle_style_form(msg=""): def handle_style_form(msg=""):
css = get_setting("custom_css") template = get_setting("custom_template") or DEFAULT_TEMPLATE
name = get_site_name() name = get_site_name()
sharing = get_setting("sharing_enabled", "0") sharing = get_setting("sharing_enabled", "0")
checked = " checked" if sharing == "1" else "" checked = " checked" if sharing == "1" else ""
@ -375,39 +499,36 @@ def handle_style_form(msg=""):
f"<h1>customize</h1>" f"<h1>customize</h1>"
f"<h2>name your search engine</h2>" f"<h2>name your search engine</h2>"
f'<form method="post" action="/style">' f'<form method="post" action="/style">'
f'{_csrf_field()}'
f'<input name="site_name" value="{esc(name)}" placeholder="tinyweb" size="30"><br><br>' f'<input name="site_name" value="{esc(name)}" placeholder="tinyweb" size="30"><br><br>'
f"<h2>sharing</h2>" f"<h2>sharing</h2>"
f'<label><input type="checkbox" name="sharing_enabled" value="1"{checked}>' f'<label><input type="checkbox" name="sharing_enabled" value="1"{checked}>'
f" share your site list publicly at /api/sites</label><br><br>" f" share your site list publicly at /api/sites</label><br><br>"
f"<h2>custom css</h2>" f"<h2>custom html</h2>"
f"<p>Some classes you can target:</p>" f"<p>Edit the full page template. Use <code>{esc('{{content}}')}</code> "
f"<pre>" f"where page content should appear.</p>"
f"body - page background, font\n" f'<textarea name="template" rows="20" cols="60">{esc(template)}</textarea><br><br>'
f"h1 - page titles\n"
f"input, button - search bar\n"
f"a - links\n"
f".result - each search result\n"
f".note - your notes on results\n"
f".trusted - trusted sites dropdown\n"
f"small - url text\n"
f"ul, li - browse page list"
f"</pre>"
f'<textarea name="css" rows="16" cols="60">{esc(css)}</textarea><br><br>'
f'<button type="submit">save</button>' f'<button type="submit">save</button>'
f"</form>" f"</form>"
f"<h2>bookmarklet</h2>" f"<h2>bookmarklet</h2>"
f"<p>Drag this link to your bookmarks bar. Click it on any page to index it instantly.</p>" f"<p>Drag this link to your bookmarks bar. Click it on any page to index it instantly.</p>"
f'<p><a href="javascript:void(fetch(\'http://localhost:8080/bookmark?url=\'+encodeURIComponent(location.href)).then(r=>r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}</a></p>' f'<p><a href="javascript:void(fetch(\'http://localhost:8080/bookmark?url=\'+encodeURIComponent(location.href)+\'&token={_get_bookmark_token()}\').then(r=>r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}</a></p>'
f"<h2>reset</h2>"
f'<form method="post" action="/style/reset">'
f'{_csrf_field()}'
f'<button type="submit">reset template to default</button>'
f"</form>"
f"<p>{msg}</p>" f"<p>{msg}</p>"
f'<a href="/">back</a>' f'<a href="/">back</a>',
use_default=True,
) )
def handle_style_submit(body): def handle_style_submit(body):
css = body.get("css", [""])[0] template = body.get("template", [""])[0].replace("\r\n", "\n").replace("\r", "\n")
name = body.get("site_name", ["tinyweb"])[0].strip() name = body.get("site_name", ["tinyweb"])[0].strip()
sharing = "1" if body.get("sharing_enabled") else "0" sharing = "1" if body.get("sharing_enabled") else "0"
set_setting("custom_css", css) set_setting("custom_template", template if template.strip() != DEFAULT_TEMPLATE.strip() else "")
set_setting("site_name", name or "tinyweb") set_setting("site_name", name or "tinyweb")
set_setting("sharing_enabled", sharing) set_setting("sharing_enabled", sharing)
return handle_style_form("Saved.") return handle_style_form("Saved.")
@ -418,10 +539,12 @@ def handle_about():
dest_hash = get_setting("dest_hash") dest_hash = get_setting("dest_hash")
sharing = get_setting("sharing_enabled", "0") == "1" sharing = get_setting("sharing_enabled", "0") == "1"
db = get_db() db = get_db()
try:
page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0] page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0] tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0]
sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0] sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0]
db.close() finally:
return_db(db)
sharing_html = ( sharing_html = (
'<p>This instance shares its index publicly. Subscribe to join the network.</p>' '<p>This instance shares its index publicly. Subscribe to join the network.</p>'
@ -467,12 +590,14 @@ def handle_about():
def handle_tags(): def handle_tags():
db = get_db() db = get_db()
try:
rows = db.execute( rows = db.execute(
"SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t " "SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t "
"JOIN page_tags pt ON t.id = pt.tag_id " "JOIN page_tags pt ON t.id = pt.tag_id "
"GROUP BY t.id ORDER BY t.name" "GROUP BY t.id ORDER BY t.name"
).fetchall() ).fetchall()
db.close() finally:
return_db(db)
items = "" items = ""
for r in rows: for r in rows:
items += f'<li><a href="/tags/{esc(r["name"])}">{esc(r["name"])}</a> ({r["cnt"]})</li>' items += f'<li><a href="/tags/{esc(r["name"])}">{esc(r["name"])}</a> ({r["cnt"]})</li>'
@ -483,14 +608,21 @@ def handle_tags():
) )
def handle_tag_browse(tag_name): def handle_tag_browse(tag_name, query=None):
page = _paginate(query or {})
offset = (page - 1) * PER_PAGE
db = get_db() db = get_db()
try:
total = db.execute(
"SELECT count(*) FROM page_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ?",
(tag_name,),
).fetchone()[0]
rows = db.execute( rows = db.execute(
"SELECT p.id, p.url, p.title, p.note FROM pages p " "SELECT p.id, p.url, p.title, p.note FROM pages p "
"JOIN page_tags pt ON p.id = pt.page_id " "JOIN page_tags pt ON p.id = pt.page_id "
"JOIN tags t ON t.id = pt.tag_id " "JOIN tags t ON t.id = pt.tag_id "
"WHERE t.name = ? ORDER BY p.id DESC", "WHERE t.name = ? ORDER BY p.id DESC LIMIT ? OFFSET ?",
(tag_name,), (tag_name, PER_PAGE, offset),
).fetchall() ).fetchall()
items = "" items = ""
for r in rows: for r in rows:
@ -501,74 +633,94 @@ def handle_tag_browse(tag_name):
f'<li>{esc(r["title"])}{note_html} {tag_links} ' f'<li>{esc(r["title"])}{note_html} {tag_links} '
f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small></li>' f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small></li>'
) )
db.close() finally:
return_db(db)
return _respond( return _respond(
f'<h1>tag: {esc(tag_name)}</h1>' f'<h1>tag: {esc(tag_name)}</h1>'
f'<p>{len(rows)} page(s)</p>' f'<p>{total} page(s)</p>'
f'<ul>{items}</ul>' f'<ul>{items}</ul>'
f'{_page_nav(page, total, f"/tags/{esc(tag_name)}")}'
f'<a href="/tags">all tags</a> | <a href="/">back</a>' f'<a href="/tags">all tags</a> | <a href="/">back</a>'
) )
def handle_api_sites(): def handle_api_sites(query=None):
if get_setting("sharing_enabled", "0") != "1": if get_setting("sharing_enabled", "0") != "1":
return _json_response( return _json_response(
{"error": "sharing disabled"}, {"error": "sharing disabled"},
status=403, status=403,
headers={"Access-Control-Allow-Origin": "*"}, headers={"Access-Control-Allow-Origin": "*"},
) )
since = (query or {}).get("since", [""])[0].strip()
db = get_db() db = get_db()
rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall() try:
if since:
rows = db.execute(
"SELECT id, url, title, note, last_modified FROM pages "
"WHERE last_modified > ? ORDER BY id DESC",
(since,),
).fetchall()
else:
rows = db.execute("SELECT id, url, title, note, last_modified FROM pages ORDER BY id DESC").fetchall()
sites = [] sites = []
for r in rows: for r in rows:
tags = _get_page_tags(r["id"], db) tags = _get_page_tags(r["id"], db)
sites.append({"url": r["url"], "title": r["title"], "note": r["note"], "tags": tags}) sites.append({
db.close() "url": r["url"], "title": r["title"], "note": r["note"],
"tags": tags, "last_modified": r["last_modified"] or "",
})
# Include list of all current URLs so subscriber can detect deletions
all_urls = [r["url"] for r in db.execute("SELECT url FROM pages").fetchall()] if not since else None
finally:
return_db(db)
data = {"name": get_site_name(), "sites": sites} data = {"name": get_site_name(), "sites": sites}
if all_urls is not None:
data["all_urls"] = all_urls
return _json_response(data, headers={"Access-Control-Allow-Origin": "*"}) return _json_response(data, headers={"Access-Control-Allow-Origin": "*"})
def handle_subscriptions(msg=""): def handle_subscriptions(msg=""):
db = get_db() db = get_db()
try:
subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall() subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
db.close() finally:
items = "" return_db(db)
cards = ""
for s in subs: for s in subs:
auto_label = "on" if s["auto_sync"] else "off" auto_label = "on" if s["auto_sync"] else "off"
last = s["last_sync"] or "never" last = s["last_sync"] or "never"
items += ( cards += (
f'<tr>' f'<div style="border:1px solid #ddd;border-radius:4px;padding:0.9rem 1rem;margin-bottom:0.75rem">'
f'<td><b>{esc(s["name"] or "unknown")}</b><br><small>{esc(s["dest_hash"])}</small></td>' f'<div style="margin-bottom:0.4rem"><b>{esc(s["name"] or "unknown")}</b></div>'
f'<td>{esc(last)}</td>' f'<div><small>{esc(s["dest_hash"])}</small></div>'
f'<td>' f'<div style="margin-top:0.4rem;font-size:0.85rem;color:#606060">last sync: {esc(last)}</div>'
f'<form method="post" action="/subscriptions/autosync/{s["id"]}" style="display:inline">' f'<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;margin-top:0.7rem">'
f'<button>auto-sync: {auto_label}</button></form>' f'<a href="/subscriptions/browse/{s["id"]}">browse</a>'
f'</td>'
f'<td>'
f'<a href="/subscriptions/browse/{s["id"]}">browse</a> '
f'<form method="post" action="/subscriptions/sync/{s["id"]}" style="display:inline">' f'<form method="post" action="/subscriptions/sync/{s["id"]}" style="display:inline">'
f'<button>sync now</button></form> ' f'{_csrf_field()}<button>sync now</button></form>'
f'<form method="post" action="/subscriptions/autosync/{s["id"]}" style="display:inline">'
f'{_csrf_field()}<button>auto-sync: {auto_label}</button></form>'
f'<form method="post" action="/subscriptions/delete/{s["id"]}" style="display:inline">' f'<form method="post" action="/subscriptions/delete/{s["id"]}" style="display:inline">'
f'<button>remove</button></form>' f'{_csrf_field()}<button>remove</button></form>'
f'</td>' f'</div>'
f'</tr>' f'</div>'
) )
table = "" listing = ""
if subs: if subs:
table = ( listing = (
f'<table><tr><th>instance</th><th>last sync</th><th>auto-sync</th><th>actions</th></tr>' f'{cards}'
f'{items}</table>'
f'<form method="post" action="/subscriptions/syncall">' f'<form method="post" action="/subscriptions/syncall">'
f'<button>sync all</button></form>' f'{_csrf_field()}<button>sync all</button></form>'
) )
return _respond( return _respond(
f"<h1>subscriptions</h1>" f"<h1>subscriptions</h1>"
f'<form method="post" action="/subscriptions/add">' f'<form method="post" action="/subscriptions/add">'
f'{_csrf_field()}'
f'<input name="dest_hash" placeholder="destination hash" size="40"> ' f'<input name="dest_hash" placeholder="destination hash" size="40"> '
f'<button>subscribe</button>' f'<button>subscribe</button>'
f'</form>' f'</form>'
f'<p>{msg}</p>' f'<p>{msg}</p>'
f'<hr>{table}' f'<hr>{listing}'
f'<br><a href="/">back</a>' f'<br><a href="/">back</a>'
) )
@ -586,8 +738,8 @@ def handle_subscription_add(body):
name = data.get("name", "") name = data.get("name", "")
except PermissionError: except PermissionError:
return handle_subscriptions("That instance has sharing disabled.") return handle_subscriptions("That instance has sharing disabled.")
except Exception as e: except Exception:
return handle_subscriptions(f"Could not reach that instance: {esc(str(e))}") return handle_subscriptions("Could not reach that instance.")
db = get_db() db = get_db()
try: try:
db.execute( db.execute(
@ -597,15 +749,15 @@ def handle_subscription_add(body):
) )
db.commit() db.commit()
finally: finally:
db.close() return_db(db)
return handle_subscriptions(f"Subscribed to {esc(name or dest_hash)}.") return handle_subscriptions(f"Subscribed to {esc(name or dest_hash)}.")
def handle_subscription_browse(sub_id): def handle_subscription_browse(sub_id):
db = get_db() db = get_db()
try:
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone() sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
if not sub: if not sub:
db.close()
return _error(404) return _error(404)
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall()) local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
@ -614,7 +766,8 @@ def handle_subscription_browse(sub_id):
"SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ?", "SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ?",
(sub_id,), (sub_id,),
).fetchall() ).fetchall()
db.close() finally:
return_db(db)
if remote_rows: if remote_rows:
sites = [] sites = []
@ -627,8 +780,8 @@ def handle_subscription_browse(sub_id):
sites = data.get("sites", []) sites = data.get("sites", [])
except PermissionError: except PermissionError:
return handle_subscriptions("That instance has sharing disabled.") return handle_subscriptions("That instance has sharing disabled.")
except Exception as e: except Exception:
return handle_subscriptions(f"Could not fetch sites: {esc(str(e))}") return handle_subscriptions("Could not fetch sites from that instance.")
new_items = "" new_items = ""
existing_items = "" existing_items = ""
@ -658,6 +811,7 @@ def handle_subscription_browse(sub_id):
f'<h1>browsing: {esc(sub["name"] or sub["dest_hash"])}</h1>' f'<h1>browsing: {esc(sub["name"] or sub["dest_hash"])}</h1>'
f'<p>{len(sites)} site(s) available, {new_count} new</p>' f'<p>{len(sites)} site(s) available, {new_count} new</p>'
f'<form method="post" action="/subscriptions/pick">' f'<form method="post" action="/subscriptions/pick">'
f'{_csrf_field()}'
f'<input type="hidden" name="sub_id" value="{sub_id}">' f'<input type="hidden" name="sub_id" value="{sub_id}">'
f'<ul>{new_items}</ul>' f'<ul>{new_items}</ul>'
f'{buttons}' f'{buttons}'
@ -673,6 +827,7 @@ def handle_subscription_pick(body):
# Build a url->tags map from remote_pages for this subscription # Build a url->tags map from remote_pages for this subscription
db = get_db() db = get_db()
try:
remote_rows = db.execute( remote_rows = db.execute(
"SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,) "SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,)
).fetchall() ).fetchall()
@ -683,7 +838,8 @@ def handle_subscription_pick(body):
urls = [r["url"] for r in remote_rows if r["url"] not in local_urls] urls = [r["url"] for r in remote_rows if r["url"] not in local_urls]
else: else:
urls = body.get("urls", []) urls = body.get("urls", [])
db.close() finally:
return_db(db)
if not urls: if not urls:
return handle_subscriptions("No sites selected.") return handle_subscriptions("No sites selected.")
@ -697,11 +853,13 @@ def handle_subscription_pick(body):
tags_str = remote_tags.get(url, "") tags_str = remote_tags.get(url, "")
if tags_str: if tags_str:
db = get_db() db = get_db()
try:
row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone() row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
if row: if row:
_set_page_tags(row["id"], tags_str, db) _set_page_tags(row["id"], tags_str, db)
db.commit() db.commit()
db.close() finally:
return_db(db)
imported += 1 imported += 1
except Exception: except Exception:
errors += 1 errors += 1
@ -710,84 +868,115 @@ def handle_subscription_pick(body):
def handle_subscription_sync(sub_id): def handle_subscription_sync(sub_id):
db = get_db() db = get_db()
try:
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone() sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
if not sub: if not sub:
db.close()
return handle_subscriptions("Subscription not found.") return handle_subscriptions("Subscription not found.")
# Use last_sync for delta sync if available
since = sub["last_sync"].replace(" ", "T") if sub["last_sync"] else ""
try: try:
data = fetch_remote_sites(sub["dest_hash"]) data = fetch_remote_sites(sub["dest_hash"], since=since)
sites = data.get("sites", []) sites = data.get("sites", [])
all_urls = data.get("all_urls")
remote_name = data.get("name", sub["name"]) remote_name = data.get("name", sub["name"])
except PermissionError: except PermissionError:
db.close()
return handle_subscriptions("That instance has sharing disabled.") return handle_subscriptions("That instance has sharing disabled.")
except Exception as e: except Exception:
db.close() return handle_subscriptions("Could not sync with that instance.")
return handle_subscriptions(f"Could not sync: {esc(str(e))}")
# Clear old remote pages for this subscription and re-insert # If full sync (all_urls provided), remove pages no longer on remote
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,)) if all_urls is not None:
existing = db.execute(
"SELECT id, url FROM remote_pages WHERE subscription_id = ?", (sub_id,)
).fetchall()
remote_url_set = set(all_urls)
for row in existing:
if row["url"] not in remote_url_set:
db.execute("DELETE FROM remote_pages WHERE id = ?", (row["id"],))
# Upsert changed/new pages
synced = 0 synced = 0
for s in sites: for s in sites:
try: try:
tags_str = ",".join(s.get("tags", [])) tags_str = ",".join(s.get("tags", []))
db.execute( db.execute(
"INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?)", "INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
(sub_id, s["url"], s["title"], s.get("note", ""), tags_str), (sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
) )
synced += 1 synced += 1
except Exception: except Exception:
pass pass
now = datetime.now().strftime("%Y-%m-%d %H:%M") now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id)) db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
db.commit() db.commit()
db.close() finally:
return_db(db)
return handle_subscriptions(f"Synced {synced} site(s) from {esc(remote_name)}.") return handle_subscriptions(f"Synced {synced} site(s) from {esc(remote_name)}.")
def handle_subscription_autosync(sub_id): def handle_subscription_autosync(sub_id):
db = get_db() db = get_db()
try:
db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,)) db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,))
db.commit() db.commit()
db.close() finally:
return_db(db)
return _redirect("/subscriptions") return _redirect("/subscriptions")
def handle_subscription_delete(sub_id): def handle_subscription_delete(sub_id):
db = get_db() db = get_db()
try:
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,)) db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,))
db.commit() db.commit()
db.close() finally:
return_db(db)
return _redirect("/subscriptions") return _redirect("/subscriptions")
def handle_subscription_syncall(): def handle_subscription_syncall():
db = get_db() db = get_db()
try:
subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall() subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall()
db.close() finally:
return_db(db)
if not subs: if not subs:
return handle_subscriptions("No subscriptions have auto-sync enabled.") return handle_subscriptions("No subscriptions have auto-sync enabled.")
total = 0 total = 0
for sub in subs: for sub in subs:
try: try:
data = fetch_remote_sites(sub["dest_hash"]) since = sub["last_sync"].replace(" ", "T") if sub["last_sync"] else ""
data = fetch_remote_sites(sub["dest_hash"], since=since)
sites = data.get("sites", []) sites = data.get("sites", [])
all_urls = data.get("all_urls")
remote_name = data.get("name", sub["name"]) remote_name = data.get("name", sub["name"])
db = get_db() db = get_db()
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub["id"],)) try:
if all_urls is not None:
existing = db.execute(
"SELECT id, url FROM remote_pages WHERE subscription_id = ?", (sub["id"],)
).fetchall()
remote_url_set = set(all_urls)
for row in existing:
if row["url"] not in remote_url_set:
db.execute("DELETE FROM remote_pages WHERE id = ?", (row["id"],))
for s in sites: for s in sites:
try: try:
tags_str = ",".join(s.get("tags", [])) tags_str = ",".join(s.get("tags", []))
db.execute( db.execute(
"INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?)", "INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
(sub["id"], s["url"], s["title"], s.get("note", ""), tags_str), (sub["id"], s["url"], s["title"], s.get("note", ""), tags_str),
) )
except Exception: except Exception:
pass pass
now = datetime.now().strftime("%Y-%m-%d %H:%M") now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub["id"])) db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub["id"]))
db.commit() db.commit()
db.close() finally:
return_db(db)
total += 1 total += 1
except Exception: except Exception:
pass pass
@ -797,7 +986,7 @@ def handle_subscription_syncall():
# --- Dispatcher --- # --- Dispatcher ---
def dispatch_request(data): def _dispatch_inner(data):
method = data.get("method", "GET") method = data.get("method", "GET")
path = data.get("path", "/") path = data.get("path", "/")
query = data.get("query", {}) query = data.get("query", {})
@ -816,13 +1005,13 @@ def dispatch_request(data):
elif path == "/add": elif path == "/add":
return handle_add_form() return handle_add_form()
elif path == "/pages": elif path == "/pages":
return handle_pages() return handle_pages(query)
elif path.startswith("/edit/"): elif path.startswith("/edit/"):
pid = extract_id("/edit/") pid = extract_id("/edit/")
return handle_edit_form(pid) if pid is not None else _error(400) return handle_edit_form(pid) if pid is not None else _error(400)
elif path.startswith("/delete/"): elif path.startswith("/delete/"):
pid = extract_id("/delete/") pid = extract_id("/delete/")
return handle_delete(pid) if pid is not None else _error(400) return handle_delete_confirm(pid) if pid is not None else _error(400)
elif path == "/bookmark": elif path == "/bookmark":
return handle_bookmark(query) return handle_bookmark(query)
elif path == "/style": elif path == "/style":
@ -836,23 +1025,31 @@ def dispatch_request(data):
elif path == "/tags": elif path == "/tags":
return handle_tags() return handle_tags()
elif path.startswith("/tags/"): elif path.startswith("/tags/"):
tag_name = path[len("/tags/"):] tag_name = unquote(path[len("/tags/"):])
return handle_tag_browse(tag_name) if tag_name else _error(400) return handle_tag_browse(tag_name, query) if tag_name else _error(400)
elif path == "/api/sites": elif path == "/api/sites":
return handle_api_sites() return handle_api_sites(query)
elif path == "/subscriptions": elif path == "/subscriptions":
return handle_subscriptions() return handle_subscriptions()
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 method == "POST": elif method == "POST":
if not _check_csrf(body):
return _respond("<h1>403 Forbidden</h1><p>Invalid or missing CSRF token.</p>", status=403)
if path == "/add": if path == "/add":
return handle_add_submit(body) return handle_add_submit(body)
elif path.startswith("/edit/"): elif path.startswith("/edit/"):
pid = extract_id("/edit/") pid = extract_id("/edit/")
return handle_edit_submit(pid, body) if pid is not None else _error(400) return handle_edit_submit(pid, body) if pid is not None else _error(400)
elif path.startswith("/delete/"):
pid = extract_id("/delete/")
return handle_delete(pid) if pid is not None else _error(400)
elif path == "/style": elif path == "/style":
return handle_style_submit(body) return handle_style_submit(body)
elif path == "/style/reset":
set_setting("custom_template", "")
return handle_style_form("Template reset to default.")
elif path == "/import": elif path == "/import":
return handle_import_submit(body) return handle_import_submit(body)
elif path == "/subscriptions/add": elif path == "/subscriptions/add":
@ -872,3 +1069,30 @@ def dispatch_request(data):
return handle_subscription_syncall() return handle_subscription_syncall()
return _error(404) return _error(404)
def dispatch_request(data):
cookies = data.get("cookies", {})
csrf_token = cookies.get("_csrf", "")
if not csrf_token:
csrf_token = secrets.token_hex(32)
_request_local.csrf_token = csrf_token
resp = _dispatch_inner(data)
resp.setdefault("headers", {})
resp["headers"]["Set-Cookie"] = f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/"
resp["headers"]["X-Frame-Options"] = "DENY"
resp["headers"]["X-Content-Type-Options"] = "nosniff"
if resp.get("content_type", "").startswith("text/html"):
resp["headers"]["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src * data:; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"
)
return resp

BIN
index.db

Binary file not shown.

View file

@ -6,11 +6,11 @@ ASPECTS = ["server"]
REQUEST_TIMEOUT = 30 REQUEST_TIMEOUT = 30
def fetch_remote_sites(dest_hash_hex): def fetch_remote_sites(dest_hash_hex, since=""):
""" """
Connect to a remote TinyWeb instance over Reticulum and fetch its Connect to a remote TinyWeb instance over Reticulum and fetch its
shared sites. Returns the response dict from /api/sites, or raises shared sites. Returns the response dict from /api/sites, or raises
an exception on failure. an exception on failure. Pass `since` as ISO timestamp for delta sync.
""" """
dest_hash = bytes.fromhex(dest_hash_hex) dest_hash = bytes.fromhex(dest_hash_hex)
@ -48,10 +48,11 @@ def fetch_remote_sites(dest_hash_hex):
try: try:
# Request /api/sites # Request /api/sites
query = {"since": [since]} if since else {}
request_data = { request_data = {
"method": "GET", "method": "GET",
"path": "/api/sites", "path": "/api/sites",
"query": {}, "query": query,
"body": {}, "body": {},
"gateway_host": "", "gateway_host": "",
} }

View file

@ -15,7 +15,26 @@ def snippet(text, query, ctx=80):
return ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "") return ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "")
def wrap_page(body_html): DEFAULT_TEMPLATE = "<html>\n<head>\n</head>\n<body>\n{{content}}\n</body>\n</html>"
css = get_setting("custom_css")
style = f"<style>{css}</style>" if css else ""
return f"<html><head>{style}</head><body>{body_html}</body></html>" def _default_template():
name = esc(get_setting("site_name", "tinyweb"))
return (
"<html>\n<head>\n</head>\n<body>\n"
f'<p><b><a href="/">{name}</a></b>'
' | <a href="/">search</a> | <a href="/pages">browse</a>'
' | <a href="/tags">tags</a> | <a href="/subscriptions">subscriptions</a>'
' | <a href="/style">customize</a> | <a href="/about">about</a></p>\n'
"<hr>\n{{content}}\n</body>\n</html>"
)
def wrap_page(body_html, use_default=False):
if use_default:
template = _default_template()
else:
template = get_setting("custom_template") or _default_template()
if "{{content}}" not in template:
template = _default_template()
return template.replace("{{content}}", body_html)

746
themes/kodama.html Normal file
View file

@ -0,0 +1,746 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'IBM Plex Sans', -apple-system, sans-serif;
font-size: 16px;
line-height: 1.65;
color: #c8c8c8;
background: #111;
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='3' fill='none' stroke='%23888' stroke-width='1.5'/%3E%3Ccircle cx='10' cy='10' r='1' fill='%23aaa'/%3E%3C/svg%3E") 10 10, default;
}
a, button, input[type="submit"], summary, label {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='6' fill='none' stroke='%23fff' stroke-width='1' opacity='0.6'/%3E%3Ccircle cx='10' cy='10' r='1.5' fill='%23fff'/%3E%3C/svg%3E") 10 10, pointer;
}
input, textarea {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cline x1='10' y1='3' x2='10' y2='17' stroke='%23aaa' stroke-width='1.5'/%3E%3C/svg%3E") 10 10, text;
}
/* scanline overlay */
body::before {
content: '';
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(255, 255, 255, 0.008) 2px,
rgba(255, 255, 255, 0.008) 4px
);
pointer-events: none;
z-index: 1000;
}
/* vignette */
body::after {
content: '';
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: radial-gradient(ellipse at center, transparent 60%, rgba(0, 0, 0, 0.4) 100%);
pointer-events: none;
z-index: 999;
}
/* floating particles canvas */
#particles {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
}
/* cursor trail canvas */
#trail {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 998;
}
/* kodama spirits */
#kodama {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 2;
}
.shell {
max-width: 660px;
margin: 0 auto;
padding: 0 1.5rem;
position: relative;
z-index: 1;
}
/* nav */
nav {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 1.5rem 0 1.2rem;
border-bottom: 1px solid #232323;
}
nav .site {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.85rem;
font-weight: 500;
color: #e8e8e8;
text-decoration: none;
letter-spacing: 0.06em;
border-bottom: none;
transition: text-shadow 0.3s;
}
nav .site:hover {
text-shadow: 0 0 8px rgba(255,255,255,0.3);
}
nav .links { display: flex; gap: 1.2rem; }
nav .links a {
font-size: 0.82rem;
color: #606060;
text-decoration: none;
border-bottom: none;
transition: color 0.2s, text-shadow 0.3s;
}
nav .links a:hover {
color: #c8c8c8;
text-shadow: 0 0 6px rgba(255,255,255,0.15);
}
/* greeting */
#greeting {
padding: 1.5rem 0 0;
font-size: 0.85rem;
color: #484848;
font-style: italic;
opacity: 0;
animation: fadeIn 1.5s ease forwards 0.3s;
}
@keyframes fadeIn {
to { opacity: 1; }
}
/* content */
.content { padding: 1.8rem 0 3rem; }
/* headings */
h1 {
font-size: 1.4rem;
font-weight: 600;
color: #e8e8e8;
margin-bottom: 1rem;
}
h1 a { color: #e8e8e8; text-decoration: none; border-bottom: none; }
h1 a:hover { color: #999; }
h2 {
font-size: 1.05rem;
font-weight: 600;
color: #d0d0d0;
margin: 1.8rem 0 0.5rem;
}
p { margin: 0.5rem 0; color: #999; }
a {
color: #c8c8c8;
text-decoration: none;
border-bottom: 1px solid #2e2e2e;
transition: all 0.2s;
}
a:hover {
color: #fff;
border-bottom-color: #555;
}
em { color: #777; }
/* inputs */
input[type="text"],
input[type="url"],
input[name="q"],
input[name="url"],
input[name="note"],
input[name="tags"],
input[name="site_name"],
input[name="dest_hash"] {
background: #181818;
border: 1px solid #2a2a2a;
border-radius: 4px;
padding: 0.6rem 0.85rem;
color: #d0d0d0;
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.3s;
}
input:focus, textarea:focus {
outline: none;
border-color: #454545;
box-shadow: 0 0 12px rgba(255,255,255,0.03);
}
button, input[type="submit"] {
background: #1c1c1c;
border: 1px solid #303030;
border-radius: 4px;
padding: 0.6rem 1.1rem;
color: #999;
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.88rem;
transition: all 0.2s;
}
button:hover, input[type="submit"]:hover {
background: #242424;
color: #d0d0d0;
border-color: #454545;
box-shadow: 0 0 10px rgba(255,255,255,0.04);
}
/* search results */
.result {
padding: 1rem 0;
border-bottom: 1px solid #1c1c1c;
transition: background 0.2s;
}
.result:hover {
background: rgba(255,255,255,0.01);
}
.result:last-child { border-bottom: none; }
.result > a:first-child {
font-size: 1.02rem;
font-weight: 500;
color: #ddd;
border-bottom: none;
}
.result > a:first-child:hover { color: #fff; }
.note {
margin-top: 0.3rem;
font-size: 0.9rem;
color: #606060;
}
.tags { margin-top: 0.3rem; }
.tag, .tags a {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.7rem;
color: #555;
border: 1px solid #252525;
border-radius: 3px;
padding: 0.1rem 0.35rem;
margin-right: 0.25rem;
}
.tag:hover, .tags a:hover {
color: #999;
border-color: #404040;
}
/* trusted / remote results */
details {
margin: 1rem 0;
border: 1px solid #1e1e1e;
border-radius: 4px;
padding: 0.7rem 0.9rem;
background: #151515;
}
summary {
font-size: 0.85rem;
color: #606060;
font-weight: 500;
}
summary:hover { color: #999; }
details ul { margin-top: 0.5rem; padding-left: 1.2rem; }
details li { margin: 0.35rem 0; font-size: 0.9rem; }
/* lists */
ul, ol { padding-left: 1.2rem; margin: 0.5rem 0; }
li { margin: 0.45rem 0; color: #999; }
li a { border-bottom: none; }
li a:hover { border-bottom: 1px solid #444; }
/* code */
pre {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.8rem;
background: #151515;
border: 1px solid #232323;
border-radius: 4px;
padding: 0.9rem;
overflow-x: auto;
color: #808080;
margin: 0.8rem 0;
}
code {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.82rem;
background: #1a1a1a;
border-radius: 3px;
padding: 0.1rem 0.35rem;
color: #999;
}
/* textarea */
textarea {
background: #151515;
border: 1px solid #2a2a2a;
border-radius: 4px;
padding: 0.7rem 0.9rem;
color: #c8c8c8;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.8rem;
line-height: 1.6;
resize: vertical;
width: 100%;
}
/* tables */
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th {
text-align: left;
font-size: 0.72rem;
font-weight: 500;
color: #505050;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.5rem 0.7rem;
border-bottom: 1px solid #232323;
}
td {
padding: 0.5rem 0.7rem;
border-bottom: 1px solid #191919;
font-size: 0.9rem;
}
/* misc */
label { color: #999; }
input[type="checkbox"] { accent-color: #555; }
hr { border: none; border-top: 1px solid #1e1e1e; margin: 1rem 0; }
small {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.7rem;
color: #484848;
}
/* footer */
footer {
border-top: 1px solid #1c1c1c;
padding: 1.5rem 0 2rem;
text-align: center;
color: #333;
font-size: 0.8rem;
}
footer .clock {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.72rem;
color: #282828;
margin-top: 0.25rem;
}
::selection { background: #333; color: #fff; }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #222; border-radius: 3px; }
@media (max-width: 600px) {
nav { flex-direction: column; gap: 0.5rem; }
nav .links { gap: 0.8rem; flex-wrap: wrap; }
h1 { font-size: 1.2rem; }
}
</style>
</head>
<body>
<canvas id="particles"></canvas>
<canvas id="trail"></canvas>
<canvas id="kodama"></canvas>
<div class="shell">
<nav>
<a class="site" href="/">tinyweb</a>
<div class="links">
<a href="/pages">browse</a>
<a href="/tags">tags</a>
<a href="/subscriptions">network</a>
<a href="/style">customize</a>
<a href="/about">about</a>
</div>
</nav>
<div id="greeting"></div>
<div class="content">
{{content}}
</div>
<footer>
<div>curated by hand · shared over mesh</div>
<div class="clock" id="clock"></div>
</footer>
</div>
<script>
(function() {
// greeting
var h = new Date().getHours();
var g = h < 5 ? "still up? the quiet hours are good for finding things." :
h < 12 ? "morning. what are you looking for?" :
h < 17 ? "afternoon. the index is ready." :
h < 21 ? "evening. settle in." :
"late night. good browsing ahead.";
document.getElementById('greeting').textContent = g;
// clock
function tick() {
var d = new Date();
var el = document.getElementById('clock');
if (el) el.textContent = d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
}
tick();
setInterval(tick, 30000);
// floating dust particles
var pc = document.getElementById('particles');
var pctx = pc.getContext('2d');
var dots = [];
function resizeParticles() {
pc.width = window.innerWidth;
pc.height = window.innerHeight;
}
resizeParticles();
window.addEventListener('resize', resizeParticles);
for (var i = 0; i < 40; i++) {
dots.push({
x: Math.random() * pc.width,
y: Math.random() * pc.height,
vy: -(Math.random() * 0.15 + 0.05),
vx: (Math.random() - 0.5) * 0.1,
r: Math.random() * 1.2 + 0.3,
o: Math.random() * 0.25 + 0.05,
drift: Math.random() * Math.PI * 2
});
}
function drawParticles() {
pctx.clearRect(0, 0, pc.width, pc.height);
var t = Date.now() * 0.001;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
d.x += d.vx + Math.sin(t + d.drift) * 0.05;
d.y += d.vy;
if (d.y < -5) { d.y = pc.height + 5; d.x = Math.random() * pc.width; }
if (d.x < -5) d.x = pc.width + 5;
if (d.x > pc.width + 5) d.x = -5;
var flicker = d.o * (0.6 + 0.4 * Math.sin(t * 1.5 + d.drift));
pctx.beginPath();
pctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(200, 200, 200, ' + flicker + ')';
pctx.fill();
}
requestAnimationFrame(drawParticles);
}
drawParticles();
// cursor trail
var tc = document.getElementById('trail');
var tctx = tc.getContext('2d');
var points = [];
var mx = 0, my = 0;
function resizeTrail() {
tc.width = window.innerWidth;
tc.height = window.innerHeight;
}
resizeTrail();
window.addEventListener('resize', resizeTrail);
document.addEventListener('mousemove', function(e) {
mx = e.clientX;
my = e.clientY;
points.push({ x: mx, y: my, t: Date.now() });
if (points.length > 30) points.shift();
});
function drawTrail() {
tctx.clearRect(0, 0, tc.width, tc.height);
var now = Date.now();
// fade out points older than 400ms
while (points.length && now - points[0].t > 400) points.shift();
if (points.length > 1) {
for (var i = 1; i < points.length; i++) {
var age = (now - points[i].t) / 400;
var alpha = (1 - age) * 0.25;
var width = (1 - age) * 2;
tctx.beginPath();
tctx.moveTo(points[i-1].x, points[i-1].y);
tctx.lineTo(points[i].x, points[i].y);
tctx.strokeStyle = 'rgba(200, 200, 200, ' + alpha + ')';
tctx.lineWidth = width;
tctx.lineCap = 'round';
tctx.stroke();
}
}
requestAnimationFrame(drawTrail);
}
drawTrail();
// kodama (tree spirits)
var kc = document.getElementById('kodama');
var kctx = kc.getContext('2d');
var spirits = [];
var numSpirits = 8;
function resizeKodama() {
kc.width = window.innerWidth;
kc.height = window.innerHeight;
}
resizeKodama();
window.addEventListener('resize', resizeKodama);
for (var i = 0; i < numSpirits; i++) {
// each spirit gets unique proportions
var headR = 0.38 + Math.random() * 0.12; // head radius ratio (bigger = bigger head)
var bodyH = 0.25 + Math.random() * 0.2; // body height ratio
var bodyW = 0.3 + Math.random() * 0.15; // body width ratio
var eyeSpread = 0.18 + Math.random() * 0.1; // how far apart eyes are
var eyeSize = 0.055 + Math.random() * 0.03; // eye dot size
var eyeY = -0.08 + Math.random() * 0.08; // eye vertical position
var hasMouth = Math.random() > 0.3; // 70% have visible mouth
var mouthSize = 0.03 + Math.random() * 0.03;
var mouthY = 0.15 + Math.random() * 0.1;
var hasArms = Math.random() > 0.4; // 60% have little arm bumps
var glowSize = 1.4 + Math.random() * 0.8; // glow radius multiplier
var glowAlpha = 0.08 + Math.random() * 0.12; // glow brightness
var tint = Math.floor(Math.random() * 15); // slight warm/cool variation
spirits.push({
x: Math.random() * 0.8 + 0.1,
baseY: 0.75 + Math.random() * 0.18,
size: 10 + Math.random() * 12,
phase: Math.random() * Math.PI * 2,
tiltSpeed: 1.2 + Math.random() * 2,
bobSpeed: 0.6 + Math.random() * 0.8,
opacity: 0,
targetOpacity: 0.4 + Math.random() * 0.45,
fadeSpeed: 0.002 + Math.random() * 0.004,
appearing: true,
timer: Math.random() * 600,
lifespan: 500 + Math.random() * 600,
rattleTime: 0,
rattling: false,
// unique shape params
headR: headR,
bodyH: bodyH,
bodyW: bodyW,
eyeSpread: eyeSpread,
eyeSize: eyeSize,
eyeY: eyeY,
hasMouth: hasMouth,
mouthSize: mouthSize,
mouthY: mouthY,
hasArms: hasArms,
glowSize: glowSize,
glowAlpha: glowAlpha,
tint: tint,
// 8 offsets that warp the head into a unique rock-like blob
hw: [
(Math.random()-0.5)*0.25, (Math.random()-0.5)*0.2,
(Math.random()-0.5)*0.2, (Math.random()-0.5)*0.25,
(Math.random()-0.5)*0.25, (Math.random()-0.5)*0.2,
(Math.random()-0.5)*0.2, (Math.random()-0.5)*0.25
],
headTall: 0.8 + Math.random() * 0.5 // overall tall vs wide
});
}
function drawKodamaSpirit(x, y, size, tilt, opacity, sp) {
kctx.save();
kctx.translate(x, y);
kctx.globalAlpha = opacity;
var r = size * sp.headR; // head radius
// outer glow aura
var grd = kctx.createRadialGradient(0, -size * 0.1, r * 0.3, 0, -size * 0.1, r * sp.glowSize);
grd.addColorStop(0, 'rgba(255, 255, 250, ' + sp.glowAlpha + ')');
grd.addColorStop(0.5, 'rgba(255, 255, 250, ' + (sp.glowAlpha * 0.3) + ')');
grd.addColorStop(1, 'rgba(255, 255, 250, 0)');
kctx.fillStyle = grd;
kctx.beginPath();
kctx.arc(0, -size * 0.1, r * sp.glowSize, 0, Math.PI * 2);
kctx.fill();
// body - stubby rounded shape
var bw = size * sp.bodyW;
var bh = size * sp.bodyH;
var by = size * 0.15;
kctx.fillStyle = 'rgb(' + (238 + sp.tint) + ',' + (237 + sp.tint) + ',' + (230 + sp.tint) + ')';
kctx.beginPath();
kctx.ellipse(0, by + bh * 0.4, bw * 0.5, bh * 0.55, 0, 0, Math.PI * 2);
kctx.fill();
// arms - tiny bumps on sides
if (sp.hasArms) {
kctx.beginPath();
kctx.ellipse(-bw * 0.5 - size * 0.04, by + bh * 0.1, size * 0.05, size * 0.04, -0.3, 0, Math.PI * 2);
kctx.fill();
kctx.beginPath();
kctx.ellipse(bw * 0.5 + size * 0.04, by + bh * 0.1, size * 0.05, size * 0.04, 0.3, 0, Math.PI * 2);
kctx.fill();
}
// head (tilts)
kctx.save();
kctx.rotate(tilt);
// head - unique rock-like blob shape per spirit
var hx = 0, hy = -size * 0.15;
var rx = r, ry = r * sp.headTall;
var w = sp.hw;
kctx.fillStyle = 'rgb(' + (243 + sp.tint) + ',' + (242 + sp.tint) + ',' + (237 + sp.tint) + ')';
kctx.beginPath();
// top
kctx.moveTo(hx + r * w[0], hy - ry);
// top-right
kctx.bezierCurveTo(
hx + rx * (0.55 + w[0]), hy - ry * (0.9 + w[1]),
hx + rx * (1.0 + w[1]), hy - ry * (0.4 + w[0]),
hx + rx * (1.0 + w[2]), hy + ry * w[2]);
// bottom-right
kctx.bezierCurveTo(
hx + rx * (1.0 + w[3]), hy + ry * (0.5 + w[2]),
hx + rx * (0.5 + w[3]), hy + ry * (1.0 + w[3]),
hx + r * w[4], hy + ry * (0.95 + w[4] * 0.3));
// bottom-left
kctx.bezierCurveTo(
hx - rx * (0.5 + w[5]), hy + ry * (1.0 + w[5]),
hx - rx * (1.0 + w[5]), hy + ry * (0.5 + w[4]),
hx - rx * (1.0 + w[6]), hy + ry * w[6]);
// top-left
kctx.bezierCurveTo(
hx - rx * (1.0 + w[7]), hy - ry * (0.4 + w[6]),
hx - rx * (0.55 + w[7]),hy - ry * (0.9 + w[7]),
hx + r * w[0], hy - ry);
kctx.fill();
// subtle inner highlight
kctx.fillStyle = 'rgba(255, 255, 252, 0.25)';
kctx.beginPath();
kctx.arc(-rx * 0.12, hy - ry * 0.1, r * 0.45, 0, Math.PI * 2);
kctx.fill();
// eyes - small round dark dots
kctx.fillStyle = 'rgba(15, 15, 15, 0.9)';
var ey = -size * 0.15 + size * sp.eyeY;
var es = size * sp.eyeSize;
kctx.beginPath();
kctx.arc(-size * sp.eyeSpread, ey, es, 0, Math.PI * 2);
kctx.fill();
kctx.beginPath();
kctx.arc(size * sp.eyeSpread, ey, es, 0, Math.PI * 2);
kctx.fill();
// mouth - tiny dot, not all have one
if (sp.hasMouth) {
kctx.fillStyle = 'rgba(15, 15, 15, 0.7)';
kctx.beginPath();
kctx.arc(0, ey + size * sp.mouthY, size * sp.mouthSize, 0, Math.PI * 2);
kctx.fill();
}
kctx.restore(); // head tilt
kctx.restore(); // position
}
function drawKodama() {
kctx.clearRect(0, 0, kc.width, kc.height);
var t = Date.now() * 0.001;
for (var i = 0; i < spirits.length; i++) {
var s = spirits[i];
s.timer++;
if (s.appearing) {
s.opacity += s.fadeSpeed;
if (s.opacity >= s.targetOpacity) s.opacity = s.targetOpacity;
if (s.timer > s.lifespan) s.appearing = false;
} else {
s.opacity -= s.fadeSpeed;
if (s.opacity <= 0) {
s.opacity = 0;
s.x = Math.random() * 0.8 + 0.1;
s.baseY = 0.75 + Math.random() * 0.18;
s.targetOpacity = 0.4 + Math.random() * 0.45;
s.timer = 0;
s.lifespan = 500 + Math.random() * 600;
s.appearing = true;
s.rattleTime = 0;
}
}
if (s.opacity <= 0) continue;
if (!s.rattling && Math.random() < 0.004) {
s.rattling = true;
s.rattleTime = 0;
}
var tilt = Math.sin(t * s.tiltSpeed + s.phase) * 0.1;
if (s.rattling) {
s.rattleTime++;
tilt = Math.sin(s.rattleTime * 0.9) * 0.35 * Math.max(0, 1 - s.rattleTime / 25);
if (s.rattleTime > 25) s.rattling = false;
}
var bobY = Math.sin(t * s.bobSpeed + s.phase) * 2.5;
var px = s.x * kc.width;
var py = s.baseY * kc.height + bobY;
drawKodamaSpirit(px, py, s.size, tilt, s.opacity, s);
}
requestAnimationFrame(drawKodama);
}
drawKodama();
})();
</script>
</body>
</html>