heartwood every commit a ring

Sync docs to current state

f12ff52a by Isaac Bythewood · 2 days ago

Sync docs to current state

CLAUDE.md: rewrite the PDF section for embedded Typst (was still
chrome-headless), drop the dead moka cache reference, refresh src/
layout to match the routes/ refactor.

README.md: drop chromium from System dependencies + Stack + features
bullet, remove the CHROMIUM_BIN env var row.

templates/properties/property_report.md: drop em dashes per style.

Add .dockerignore matching blog/status.
added .dockerignore
@@ -0,0 +1,4 @@/target/frontend/node_modules/.git/dist
modified CLAUDE.md
@@ -16,7 +16,7 @@ Single-binary axum app, no Django, no multi-user. Password from `.env`. Original- **Pull production data:** `make pull` (rsync db + geoip from `git remote server`)- **Rebuild map data:** `make maps` (regenerates per-country topojson)- **Seed fake data:** `make seed` (creates/refreshes a "Seed Test" property with realistic events; override `SESSIONS=2000 DAYS=60`)- **Import a Django DB:** `make migrate FROM=<path-to-django.sqlite3> [FORCE=1]` — one-shot import that preserves property UUIDs (so embedded snippets keep working). Same logic is exposed on the binary as `./analytics migrate <path> [--force]` so the production Docker image can run it.- **Import a Django DB:** `make migrate FROM=<path-to-django.sqlite3> [FORCE=1]`: one-shot import that preserves property UUIDs (so embedded snippets keep working). Same logic is exposed on the binary as `./analytics migrate <path> [--force]` so the production Docker image can run it.- **Docker build:** `sudo docker build .`There are no tests or linters configured.
@@ -25,7 +25,7 @@ There are no tests or linters configured.**Backend:** Single-binary axum app (`src/main.rs`). Async sqlx + sqlite (WAL, `synchronous=NORMAL`, `busy_timeout=5s`, foreign keys on) for both reads and writes. Schema lives in `migrations/0001_initial.sql` and is applied automatically on boot. State is shared via `AppState` (template env, db pool, cookie key, geoip + ua parsers, dashboard cache, config).**Auth:** Single password from `ANALYTICS_PASSWORD` in `.env`. Login form posts to `/login`; on success a signed cookie (`SameSite=Strict`, 30-day max-age) is set. The cookie payload is just `1:<exp-timestamp>`, signed with `tower-cookies`. The cookie key is `ANALYTICS_COOKIE_SECRET` if set, else derived from the password via SHA-512 (so changing the password invalidates sessions). No CSRF tokens — `SameSite=Strict` is the protection.**Auth:** Single password from `ANALYTICS_PASSWORD` in `.env`. Login form posts to `/login`; on success a signed cookie (`SameSite=Strict`, 30-day max-age) is set. The cookie payload is just `1:<exp-timestamp>`, signed with `tower-cookies`. The cookie key is `ANALYTICS_COOKIE_SECRET` if set, else derived from the password via SHA-512 (so changing the password invalidates sessions). No CSRF tokens; `SameSite=Strict` is the protection.**Data model:** Three tables. `properties` holds tracked sites (UUID PK, name, custom_cards JSON, is_protected, is_public). `events` stores human events with hot fields extracted into typed columns (url, referrer, user_agent, country, region, city, lat/lon, screen size, platform/browser/device, utm_*, time_on_page_ms, user_id) plus an `extra` JSON blob for custom keys. `bot_events` is a separate table with the small subset of fields useful for bot reporting; bot traffic is routed there at write time so human dashboard queries never have to filter it. `meta` is a key-value table for things like the Proprium property id.
@@ -33,17 +33,15 @@ There are no tests or linters configured.**Self-tracking (Proprium):** On first boot the binary auto-creates a "Proprium" property with `is_protected=1` and stores its UUID in the `meta` table. The base template renders a collector snippet pointing at that property when `BASE_URL` is set, so the app tracks its own usage like any other site.**Auto-downloads:** On boot, two best-effort background tasks download `data/db.mmdb` (DB-IP City Lite, CC-BY-4.0, no signup) if missing or older than 30 days, and `data/regexes.yaml` (canonical ua-parser regexes from the `ua-parser/uap-core` repo, Apache-2.0) if missing. Failures are logged and the server still boots — UA parsing falls back to a substring heuristic and GeoIP enrichment is skipped.**Auto-downloads:** On boot, two best-effort background tasks download `data/db.mmdb` (DB-IP City Lite, CC-BY-4.0, no signup) if missing or older than 30 days, and `data/regexes.yaml` (canonical ua-parser regexes from the `ua-parser/uap-core` repo, Apache-2.0) if missing. Failures are logged and the server still boots. UA parsing falls back to a substring heuristic and GeoIP enrichment is skipped.**Templates:** Jinja2 templates in `templates/` rendered by minijinja with a Jinja2-faithful HTML formatter so `/` is not escaped to `&#x2f;` (matches blog/darkfurrow). The `vite_asset` global resolves hashed asset names by reading `dist/.vite/manifest.json` — re-read per call in debug builds (Vite watcher rebuilds show up immediately), cached at startup in release builds.**Templates:** Jinja2 templates in `templates/` rendered by minijinja with a Jinja2-faithful HTML formatter so `/` is not escaped to `&#x2f;` (matches status/blog/darkfurrow). The `vite_asset` global resolves hashed asset names by reading `dist/.vite/manifest.json`: re-read per call in debug builds (Vite watcher rebuilds show up immediately), cached at startup in release builds.**Frontend pipeline:** Vite (run from `frontend/`) builds `frontend/static_src/` into `dist/`. Four entry points: `base` (Bootstrap 5 SCSS + monaspace font + the bootstrap JS shell), `pages` (marketing pages), `properties` (dashboard charts + map), `collector` (the public embed script). Output filenames are content-hashed and served at `/static/`.**Map data:** `frontend/scripts/build_maps.js` (run at Docker build time via `bun run build:maps`) downloads Natural Earth admin-0 110m + admin-1 10m GeoJSON and writes per-country TopoJSON files into `static_maps/`. The Rust binary serves that directory at `/static_maps/`. The world view ships with the dashboard; per-country admin-1 is lazy-fetched on click.**Dashboard cache:** `moka` in-memory cache keyed by `dash:<property>:<updated_at>:<dates>:<filter_url>`, 5-minute TTL. `?report=pdf|md` bypasses the cache so exports match the live view.**PDF generation:** `src/pdf.rs` spawns chrome-headless-shell via `--print-to-pdf` against a temp `.html` file. Chromium is located via `CHROMIUM_BIN`, then PATH search, then a `/opt/playwright-browsers/` glob fallback (lifted directly from blog/darkfurrow).**PDF generation:** `src/pdf.rs` embeds the Typst compiler (`typst` + `typst-pdf`) as a library, no chromium subprocess. `PdfRenderer::new` runs typst-kit's font searcher once at startup and shares the resulting library/book/font slots across renders. The dashboard route renders a Typst template through minijinja first (using the `typst_md` and `typst_str` filters in `templates.rs` to escape user data into Typst-safe markup), then passes the resulting source to `PdfRenderer::render` inside `tokio::task::spawn_blocking` (Typst compilation is CPU-bound and synchronous). `?report=md` returns the markdown variant directly.**Request logging:** `src/main.rs::log_requests` middleware prints `time METHOD STATUS latency path` per request with ANSI-colored status codes (green 2xx, cyan 3xx, yellow 4xx, red 5xx). Sub-microsecond cost.
@@ -55,19 +53,26 @@ analytics/├── Makefile, README.md           # top-level├── migrations/                   # sqlx migrations (0001_initial.sql)├── src/                          # rust source│   ├── main.rs        # axum app, AppState, middleware, route table│   ├── auth.rs        # signed cookie, login/logout, is_authenticated│   ├── views.rs       # /properties + /<id> dashboard (CRUD + render)│   ├── pages.rs       # /, /changelog, /documentation, /favicon.ico, /robots.txt, /sitemap.xml│   ├── collector.rs   # POST /collect with UA + GeoIP enrichment + bot routing│   ├── main.rs        # tiny entry: env init, subcommand dispatch, server boot│   ├── app.rs         # AppState::from_env + router() (assembles per-feature routes)│   ├── render.rs      # render() helper (injects standard ctx)│   ├── middleware.rs  # request log + 404 handler│   ├── routes/        # per-feature route modules, each exposing fn router()│   │   ├── mod.rs│   │   ├── auth.rs        # /login, /logout, is_authenticated│   │   ├── home.rs        # /, /changelog, /documentation│   │   ├── seo.rs         # /favicon.ico, /robots.txt, /sitemap.xml│   │   ├── properties.rs  # /properties (list/create/delete/custom-cards/visibility)│   │   ├── dashboard.rs   # /<id> dashboard, ?report=pdf|md│   │   └── collector.rs   # POST /collect with UA + GeoIP enrichment + bot routing│   ├── db.rs          # sqlx pool init, migrate, ensure_proprium│   ├── models.rs      # Property + PropertyRow structs│   ├── queries.rs     # dashboard aggregations (in-progress port of properties/queries.py)│   ├── queries.rs     # dashboard aggregations│   ├── geoip.rs       # maxminddb wrapper + DB-IP auto-download + reload│   ├── ua.rs          # uap-rs wrapper + regexes auto-download│   ├── cache.rs       # moka 5-min dashboard cache│   ├── markdown.rs    # comrak wrapper│   ├── pdf.rs         # chrome-headless-shell subprocess│   ├── pdf.rs         # embedded Typst renderer│   ├── migrate.rs     # `./analytics migrate <django.sqlite3>`│   ├── bin/seed.rs    # `cargo run --bin seed` to seed a "Seed Test" property│   └── templates.rs   # minijinja env, vite_asset, url_for, jinja2-compat formatter├── templates/                    # minijinja-compatible jinja2│   ├── base.html      # layout
@@ -94,20 +99,20 @@ The binary reads `templates/`, `dist/`, `migrations/`, and `static_maps/` from c## Key Routes- `/` — marketing home (redirects to `/properties` when authenticated)- `/login`, `/logout` — single-password auth- `/properties` — list + create + delete + custom cards + visibility toggle (auth required)- `/<property-id>` — dashboard (auth required unless property is public). Accepts `?date_start`, `?date_end`, `?date_range`, `?filter_url`, `?report=pdf|md`.- `/collect`, `/collect/` — public collector endpoint (CORS-open, accepts JSON POST)- `/changelog`, `/documentation`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml` — static pages- `/static/*` — Vite assets (1y cache header)- `/static_maps/*` — topojson per country (1y cache header)- `/`: marketing home (redirects to `/properties` when authenticated)- `/login`, `/logout`: single-password auth- `/properties`: list + create + delete + custom cards + visibility toggle (auth required)- `/<property-id>`: dashboard (auth required unless property is public). Accepts `?date_start`, `?date_end`, `?date_range`, `?filter_url`, `?report=pdf|md`.- `/collect`, `/collect/`: public collector endpoint (CORS-open, accepts JSON POST)- `/changelog`, `/documentation`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml`: static pages- `/static/*`: Vite assets (1y cache header)- `/static_maps/*`: topojson per country (1y cache header)## Tooling- **Rust deps:** managed with `cargo` (`Cargo.toml`, `Cargo.lock`)- **JS deps:** managed with `bun`, run from `frontend/` (`frontend/package.json`, `frontend/bun.lock`)- **Production:** Docker (`rust:alpine` builder + `alpine:3.23` runtime, `chromium` apk for PDF), deployed via `git push server master` triggering a post-receive hook that runs `docker compose up --build --detach`. Data persisted to `/srv/data/analytics/`.- **Production:** Docker (`rust:alpine` builder + `alpine:3.23` runtime, no chromium since PDF is embedded Typst). Runtime image installs `font-jetbrains-mono`, `ttf-dejavu`, `ttf-liberation`, and `fontconfig` so Typst can find a body sans, mono, and fallback fonts. Deployed via `git push server master` triggering a post-receive hook that runs `docker compose up --build --detach`. Data persisted to `/srv/data/analytics/`.## Status of the port
@@ -119,5 +124,4 @@ The port is feature-complete against the original Django version:Possible future work (none of it required for parity):- Custom-cards POST currently accepts a JSON array but the in-page form uses checkbox names. The form-handler JS posts JSON, so this works; if the JS ever stops sending JSON, the endpoint would need to accept form-encoded too.- `?report=pdf` template links the production base_url for assets. In dev, leave `BASE_URL` unset and chromium will inline the relative paths.- Auto-download of the DB-IP mmdb fails on the first day or two of every month while DB-IP rolls the new file. The code retries on next boot — for production set up `restic-status`-style monthly cron via `make pull` if you need stronger guarantees.- Auto-download of the DB-IP mmdb fails on the first day or two of every month while DB-IP rolls the new file. The code retries on next boot. For production set up `restic-status`-style monthly cron via `make pull` if you need stronger guarantees.
modified README.md
@@ -9,7 +9,7 @@ Self-hosted website analytics, single-operator. One axum binary with sqlx + SQLi- GeoIP enrichment (auto-downloads DB-IP City Lite) and UA parsing (auto-downloads ua-parser regexes)- Bot traffic routed to a separate table so human dashboards never have to filter it- Public/private toggle per property; signed-cookie auth for the operator- PDF and markdown export of any dashboard view (Chromium-rendered PDF)- PDF and markdown export of any dashboard view (PDF rendered in-process via embedded Typst, no chromium)- Single-binary deploy via `git push server master`## System dependencies
@@ -22,7 +22,6 @@ Local dev needs all of these on your `PATH`:| `bun` | Frontend deps + Vite + map builder | 1.x || `make` | Run the dev/build targets | any || `pkg-config` + OpenSSL headers | Linked at build time on Linux | distro packages: `pkg-config`, `libssl-dev` (Debian/Ubuntu), `openssl-dev` (Alpine) || `chromium` | PDF report export only; everything else works without it | any recent build |Install hints:
@@ -34,10 +33,10 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shcurl -fsSL https://bun.sh/install | bash# System libs (Debian/Ubuntu)sudo apt install -y build-essential pkg-config libssl-dev chromiumsudo apt install -y build-essential pkg-config libssl-dev# System libs (Alpine)sudo apk add musl-dev pkgconfig openssl-dev chromiumsudo apk add musl-dev pkgconfig openssl-dev```The Docker build (see `Dockerfile`) reproduces this on `rust:alpine` + `alpine:3.23`. If you only care about Docker, you do not need any of the above on the host.
@@ -73,7 +72,6 @@ All config comes from `.env` (loaded via `dotenvy`). The full set:| `ANALYTICS_COOKIE_SECRET` | no | 32+ bytes for signing the session cookie. Falls back to a SHA-512 of the password, so rotating the password invalidates sessions || `ANALYTICS_DATA_DIR` | no (default `./data`) | Where the SQLite db, mmdb, and regexes live. Production sets this to `/data` || `ANALYTICS_ROOT` | no | Override the project root (where `templates/`, `dist/`, `migrations/`, `static_maps/` are read from) || `CHROMIUM_BIN` | no | Path to chromium for PDF export. Falls back to `PATH` lookup, then a `/opt/playwright-browsers/` glob |## Make targets
@@ -106,7 +104,7 @@ Data persists to `/srv/data/analytics/` on the host (mounted into the container- **Backend:** axum 0.8, sqlx 0.8 against SQLite (WAL, `synchronous=NORMAL`, `busy_timeout=5s`), tower-cookies for signed sessions- **Templates:** minijinja 2 with a Jinja2-faithful HTML formatter- **Frontend:** Vite 6, Bootstrap 5 SCSS, Chart.js, d3-geo + topojson, monaspace argon font (self-hosted via `@fontsource`)- **Enrichment:** maxminddb (GeoIP), uaparser (UA), moka (5-minute dashboard cache)- **PDF:** chrome-headless-shell via `--print-to-pdf` against a temp html file- **Enrichment:** maxminddb (GeoIP), uaparser (UA)- **PDF:** embedded Typst (`typst` + `typst-pdf` + `typst-kit`), no chromium subprocessSee [CLAUDE.md](CLAUDE.md) for the full architecture rundown, route table, and data model.
modified templates/properties/property_report.md
@@ -127,4 +127,4 @@{% endif %}---_Report generated by Analytics — self-hosted website analytics.__Report generated by Analytics, self-hosted website analytics._