rob thijssen e63583877c feat(api): server-rendered OG image of all-time contribution graph
Add /v1/og/contributions.png endpoint that builds an SVG of the
all-time weekly contribution graph (one row per year) from daily
counts, then rasterizes to PNG via resvg. Served with 1h cache.

Add og:image and twitter:card meta tags to index.html pointing at
the endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 17:37:19 +03:00

moments

Personal activity timeline. Polls public sources (GitHub, Gitea, Mercurial, Bugzilla), stores raw payloads in Postgres, and serves a reshaped timeline to a static React frontend.

Successor to the now-defunct grenade-events-react, which depended on MongoDB Stitch (retired by MongoDB in September 2022).

Layout

crates/
  moments-entities/   # types and DTOs
  moments-core/       # ingestion + reshape logic
  moments-data/       # postgres adapter + migrations
  moments-api/        # axum read-only HTTP API (binary)
  moments-worker/     # ingestion daemon (binary)
ui/                   # vite + react + swc + ts frontend
asset/                # systemd, nginx, firewalld, manifest.yml
script/deploy.sh

Architectural conventions follow grenade/architecture/generic.md.

Local development

cargo build --workspace
cargo run -p moments-api      # serves on 127.0.0.1:8080
cargo run -p moments-worker   # one-shot ingest tick (until --interval is wired up)

The API expects a Postgres reachable at DATABASE_URL. In production this is an mTLS connection using the host cert. For local dev against a throwaway database:

DATABASE_URL=postgres://localhost/moments cargo run -p moments-api

Migrations live in crates/moments-data/migrations/ and run automatically on worker startup. The API connects as moments_ro and never runs migrations — the worker (as moments_rw) is the schema owner.

Deployment

./script/deploy.sh <env> all          # api + worker + web
./script/deploy.sh <env> api worker   # subset
./script/deploy.sh <env> default      # api + web only (worker untouched)
./script/deploy.sh <env> all --dry-run

Concrete hosts, ports, and the site's server_name live in asset/manifest.yml. The shape of the deployment:

Component Notes
api binds the port from api.config.bind; firewalld service moments-api
worker no listening port; pollers only
web per-site nginx ingress; /api/* reverse-proxies to the api host
db postgres mTLS, passwordless

Postgres roles moments_rw and moments_ro must exist on the primary, with pg_ident.conf.d/<host>.conf mapping the api host's FQDN → moments_ro and the worker host's FQDN → moments_rw. See asset/sql/bootstrap-moments.sql, asset/postgres/ident.conf.tmpl, and script/db-perms.sh (idempotently adds the cert_cn lines on the postgres primary + standby and reloads postgres).

Inter-host traffic over the WG mesh: web's nginx connects to the api host in plaintext. The mesh provides the encryption layer; per-hop TLS for an internal HTTP read-only API on already-public data is deferred. If that changes, swap the api binary to rustls + the host cert pair, and update the nginx upstream to https://.

Secrets are resolved at deploy time via pass. The mapping of env-var name → pass-store path lives under worker.secrets in manifest.yml; deploy.sh iterates the map, fetches each secret, and substitutes the matching {{NAME}} placeholder in worker.env.tmpl. To add a secret: add a worker.secrets entry, add NAME={{NAME}} to worker.env.tmpl, and ensure pass show <path> returns the value on the deploying machine.

First-time setup

After the first successful prod deploy:

  1. Point public DNS for the site at the web host's public IP (unproxied).
  2. Confirm curl --fail --silent --show-error https://<site>/api/v1/healthz returns ok.
  3. If migrating from a predecessor, archive the old repo with a pointer to this one.
Description
Personal activity timeline for rob.tn — successor to grenade-events-react
Readme 1.4 MiB
Languages
Rust 61.4%
TypeScript 24.3%
Shell 12.1%
CSS 1.4%
HTML 0.8%