Compare commits
23 commits
e0a12272ed
...
3c6c941ad2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6c941ad2 | ||
|
|
2df92752b6 | ||
|
|
6070e09834 | ||
|
|
7c225145f0 | ||
|
|
ffdfb821c8 | ||
|
|
fe0e15edc4 | ||
|
|
2adef21ec6 | ||
|
|
b49b1df8b5 | ||
|
|
5f8863ce77 | ||
|
|
ddaba43710 | ||
|
|
fede0287e9 | ||
|
|
f2e8dd042a | ||
|
|
6981d39ddd | ||
|
|
d2cb0d00bc | ||
|
|
86e4c1f151 | ||
|
|
c10aa7955c | ||
|
|
d5f2d01651 | ||
|
|
9ddecf71db | ||
|
|
9c4ed9ac9e | ||
|
|
862a383101 | ||
|
|
b17988fc95 | ||
|
|
8741c2fffb | ||
|
|
4df0ef03f5 |
14 changed files with 1692 additions and 352 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
__pycache__/
|
||||||
|
index.db*
|
||||||
|
tinyweb_identity
|
||||||
|
.git/
|
||||||
|
*.md
|
||||||
43
CLAUDE.md
Normal file
43
CLAUDE.md
Normal 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
18
Dockerfile
Normal 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"]
|
||||||
75
README.md
75
README.md
|
|
@ -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
43
app.py
|
|
@ -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)
|
||||||
|
|
|
||||||
165
db.py
165
db.py
|
|
@ -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()
|
||||||
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
try:
|
||||||
db.close()
|
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
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()
|
||||||
db.execute(
|
try:
|
||||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
db.execute(
|
||||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||||
(key, value),
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
)
|
(key, value),
|
||||||
db.commit()
|
)
|
||||||
db.close()
|
db.commit()
|
||||||
|
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,18 +307,22 @@ 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",
|
|
||||||
(url, title, body, note),
|
|
||||||
)
|
|
||||||
page_id = cur.lastrowid
|
|
||||||
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
|
||||||
for href, label in links:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO links (page_id, url, label) VALUES (?, ?, ?)",
|
"INSERT INTO pages (url, title, body, note, last_modified) VALUES (?, ?, ?, ?, ?) "
|
||||||
(page_id, href, label),
|
"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),
|
||||||
)
|
)
|
||||||
db.commit()
|
page_id = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()[0]
|
||||||
db.close()
|
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
||||||
|
for href, label in links:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO links (page_id, url, label) VALUES (?, ?, ?)",
|
||||||
|
(page_id, href, label),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
return_db(db)
|
||||||
return title
|
return title
|
||||||
|
|
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal 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
33
entrypoint.sh
Executable 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
|
||||||
11
gateway.py
11
gateway.py
|
|
@ -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}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
854
handlers.py
854
handlers.py
File diff suppressed because it is too large
Load diff
BIN
index.db
BIN
index.db
Binary file not shown.
|
|
@ -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": "",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
templates.py
27
templates.py
|
|
@ -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
746
themes/kodama.html
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue