diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..42c7cc3 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,354 @@ +name: deploy + +# Build and roll out all three moments components on a push to main: +# - moments-api (static musl binary, read-only API) -> nikola +# - moments-worker (static musl binary, ingestion daemon) -> frootmig +# - web (prerendered static SPA + nginx vhost) -> oolon +# +# This workflow is the source of infra truth (hosts, ports, paths live in `env` +# below — there is no manifest.yml in this model). It replaces script/deploy.sh: +# instead of an operator running it from a workstation with `pass`, a Gitea +# Actions runner deploys as the dedicated `gitea_ci` user over SSH, with secrets +# from the repo settings and scoped sudo (see asset/sudoers.d/ + script/infra-setup.sh). +# +# The api/worker binaries are pure-rustls (no openssl), so they build as fully +# static musl — a runner newer than the target host can't produce an unloadable +# glibc binary (architecture/deployment-gitea-actions.md §6). +# +# Secrets (Gitea -> repo -> Settings -> Actions -> Secrets): +# RSYNC_SSH_KEY private SSH key whose pubkey script/infra-setup.sh installed +# QUERY_GITHUB_TOKEN github api token for the worker's poller +# QUERY_GITEA_TOKEN git.lair.cafe api token for the worker's poller +# (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names, hence the QUERY_ prefix.) +# +# One-time per-host provisioning (gitea_ci user, authorized_keys, scoped +# sudoers, and the postgres mTLS host cert the api/worker need) is done by +# script/infra-setup.sh — run it once per host before this workflow can succeed. + +on: + push: + branches: [main] + workflow_dispatch: + +# Serialize deploys; never cancel an in-flight one (a half-applied rollout is +# worse than a slightly stale one). +concurrency: + group: deploy + cancel-in-progress: false + +env: + # --- infra truth (mirrors the former asset/manifest.yml) --- + API_HOST: nikola.kosherinata.internal + WORKER_HOST: frootmig.kosherinata.internal + WEB_HOST: oolon.kosherinata.internal + API_BIND: 0.0.0.0:42424 + API_PORT: "42424" + SERVER_NAME: rob.tn + WEB_ROOT: /var/www/rob.tn + API_UPSTREAM_SCHEME: http + API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424 + MUSL_TARGET: x86_64-unknown-linux-musl + # The prerender fetches the live public API at build time; the client bundle + # keeps the same-origin relative /api/v1 that nginx proxies. + VITE_API_BASE: https://rob.tn/api/v1 + DEPLOY_KEY: | + ${{ secrets.RSYNC_SSH_KEY }} + +jobs: + build: + name: Build api + worker + web + runs-on: rust + steps: + - uses: actions/checkout@v4 + + # The `rust` runner image provides cargo/clippy/rustfmt, musl-gcc, the + # x86_64-unknown-linux-musl std, and node. No package installs at run time. + - name: Lint + test (deploy gate) + run: | + cargo fmt --check --all + cargo clippy --workspace --all-targets -- -D warnings + cargo test --workspace + + - name: Build moments-api + moments-worker (static musl release) + run: cargo build --release --target "${MUSL_TARGET}" -p moments-api -p moments-worker + + - name: Build web (vite client + prerender) + run: | + corepack enable + corepack prepare pnpm@10 --activate + pnpm --dir ui install --frozen-lockfile + pnpm --dir ui run build + + - name: Stage binaries + run: | + mkdir --parents artifacts + cp "target/${MUSL_TARGET}/release/moments-api" artifacts/moments-api + cp "target/${MUSL_TARGET}/release/moments-worker" artifacts/moments-worker + + - uses: actions/upload-artifact@v3 + with: { name: moments-api, path: artifacts/moments-api, retention-days: 1 } + - uses: actions/upload-artifact@v3 + with: { name: moments-worker, path: artifacts/moments-worker, retention-days: 1 } + - uses: actions/upload-artifact@v3 + with: { name: web-dist, path: ui/dist, retention-days: 1 } + + deploy-api: + name: Deploy moments-api to nikola + needs: build + runs-on: fedora-43 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: { name: moments-api, path: artifact } + + - name: SSH init + run: | + mkdir -p ~/.ssh + echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \ + gitea_ci@"${API_HOST}" 'hostname -f' + + - name: Render config + units from infra truth + run: | + mkdir -p rendered + # HOSTNAME is the host's own fqdn (used for the postgres mTLS cert path). + python3 - "$API_HOST" "$API_BIND" "$API_PORT" <<'PY' + import sys + host, bind, port = sys.argv[1:4] + def render(src, dst, subs): + t = open(src).read() + for k, v in subs.items(): + t = t.replace("{{%s}}" % k, v) + open(dst, "w").write(t) + render("asset/config/api.env.tmpl", "rendered/api.env", + {"HOSTNAME": host, "BIND": bind}) + render("asset/systemd/moments-api-cert.path", "rendered/moments-api-cert.path", + {"HOSTNAME": host}) + render("asset/firewalld/moments-api.xml.tmpl", "rendered/moments-api.xml", + {"API_PORT": port}) + PY + + - name: Provision service account + directories + run: | + # --mkpath: /etc/sysusers.d and /etc/firewalld/services don't exist by + # default on Fedora (only the /usr/lib variants ship). + rsync -az --mkpath --rsync-path='sudo rsync' \ + asset/systemd/moments.sysusers.conf \ + gitea_ci@"${API_HOST}":/etc/sysusers.d/moments.conf + ssh gitea_ci@"${API_HOST}" ' + set -euo pipefail + sudo /usr/bin/systemd-sysusers + sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments + sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments' + + - name: Sync binary, env, units, firewalld service + run: | + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \ + artifact/moments-api gitea_ci@"${API_HOST}":/usr/local/bin/moments-api + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \ + rendered/api.env gitea_ci@"${API_HOST}":/etc/moments/api.env + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + asset/systemd/moments-api.service \ + gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api.service + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + rendered/moments-api-cert.path \ + gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert.path + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + asset/systemd/moments-api-cert-reload.service \ + gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert-reload.service + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + rendered/moments-api.xml \ + gitea_ci@"${API_HOST}":/etc/firewalld/services/moments-api.xml + + - name: Apply SELinux + firewalld + cert ACL (idempotent) + run: | + ssh gitea_ci@"${API_HOST}" ' + set -euo pipefail + fqdn="$(hostname -f)" + # Let the moments user read the host private key for postgres mTLS. + sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem" + sudo /usr/sbin/restorecon -R /usr/local/bin/moments-api /etc/moments /var/lib/moments + if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then + sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"' + fi + # firewalld only learns a freshly-shipped custom service after reload. + sudo /usr/bin/firewall-cmd --reload + if ! sudo /usr/bin/firewall-cmd --query-service=moments-api; then + sudo /usr/bin/firewall-cmd --add-service=moments-api --permanent + sudo /usr/bin/firewall-cmd --reload + fi' + + - name: Restart moments-api + run: | + ssh gitea_ci@"${API_HOST}" ' + set -euo pipefail + sudo /usr/bin/systemctl daemon-reload + sudo /usr/bin/systemctl enable --now moments-api-cert.path + sudo /usr/bin/systemctl enable moments-api.service + sudo /usr/bin/systemctl restart moments-api.service' + + - name: Health probe + run: | + for i in 1 2 3 4 5 6 7 8 9 10; do + if ssh gitea_ci@"${API_HOST}" "curl -fsS http://127.0.0.1:${API_PORT}/v1/healthz"; then + echo; echo "moments-api healthy"; exit 0 + fi + sleep 2 + done + echo "moments-api did not become healthy" >&2 + exit 1 + + - name: Capture startup journal + if: always() + run: | + sleep 3 + ssh gitea_ci@"${API_HOST}" 'journalctl --unit moments-api.service --no-pager -n 200' + + deploy-worker: + name: Deploy moments-worker to frootmig + needs: build + runs-on: fedora-43 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: { name: moments-worker, path: artifact } + + - name: SSH init + run: | + mkdir -p ~/.ssh + echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \ + gitea_ci@"${WORKER_HOST}" 'hostname -f' + + - name: Render worker config + cert.path from secrets + env: + # GITHUB_TOKEN / GITEA_TOKEN are reserved Actions secret names, so the + # repo secrets are QUERY_*; the rendered worker.env still uses the + # GITHUB_TOKEN / GITEA_TOKEN env vars the worker poller expects. + GITHUB_TOKEN: ${{ secrets.QUERY_GITHUB_TOKEN }} + GITEA_TOKEN: ${{ secrets.QUERY_GITEA_TOKEN }} + run: | + mkdir -p rendered + # Literal substitution via python so tokens with shell-special chars + # survive; secrets come from the environment, never a command line. + python3 - "$WORKER_HOST" <<'PY' + import os, sys + host = sys.argv[1] + t = open("asset/config/worker.env.tmpl").read() + subs = { + "HOSTNAME": host, + "GITHUB_TOKEN": os.environ.get("GITHUB_TOKEN", ""), + "GITEA_TOKEN": os.environ.get("GITEA_TOKEN", ""), + } + for k, v in subs.items(): + t = t.replace("{{%s}}" % k, v) + open("rendered/worker.env", "w").write(t) + c = open("asset/systemd/moments-worker-cert.path").read().replace("{{HOSTNAME}}", host) + open("rendered/moments-worker-cert.path", "w").write(c) + PY + + - name: Provision service account + directories + run: | + rsync -az --mkpath --rsync-path='sudo rsync' \ + asset/systemd/moments.sysusers.conf \ + gitea_ci@"${WORKER_HOST}":/etc/sysusers.d/moments.conf + ssh gitea_ci@"${WORKER_HOST}" ' + set -euo pipefail + sudo /usr/bin/systemd-sysusers + sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments + sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments' + + - name: Sync binary, env, units + run: | + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \ + artifact/moments-worker gitea_ci@"${WORKER_HOST}":/usr/local/bin/moments-worker + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \ + rendered/worker.env gitea_ci@"${WORKER_HOST}":/etc/moments/worker.env + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + asset/systemd/moments-worker.service \ + gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker.service + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + rendered/moments-worker-cert.path \ + gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert.path + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + asset/systemd/moments-worker-cert-reload.service \ + gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert-reload.service + + - name: Apply cert ACL + SELinux, restart worker + run: | + ssh gitea_ci@"${WORKER_HOST}" ' + set -euo pipefail + fqdn="$(hostname -f)" + sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem" + sudo /usr/sbin/restorecon -R /usr/local/bin/moments-worker /etc/moments /var/lib/moments + sudo /usr/bin/systemctl daemon-reload + sudo /usr/bin/systemctl enable --now moments-worker-cert.path + sudo /usr/bin/systemctl enable moments-worker.service + sudo /usr/bin/systemctl restart moments-worker.service + sudo /usr/bin/systemctl is-active --quiet moments-worker.service' + + - name: Capture startup journal + if: always() + run: | + sleep 3 + ssh gitea_ci@"${WORKER_HOST}" 'journalctl --unit moments-worker.service --no-pager -n 200' + + deploy-web: + name: Deploy web to oolon + needs: build + runs-on: fedora-43 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: { name: web-dist, path: dist } + + - name: SSH init + run: | + mkdir -p ~/.ssh + echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \ + gitea_ci@"${WEB_HOST}" 'hostname -f' + + - name: Render nginx vhost + run: | + mkdir -p rendered + python3 - <<'PY' + import os + t = open("asset/nginx/site.conf.tmpl").read() + subs = { + "SERVER_NAME": os.environ["SERVER_NAME"], + "DOCROOT": os.environ["WEB_ROOT"], + "API_UPSTREAM_SCHEME": os.environ["API_UPSTREAM_SCHEME"], + "API_UPSTREAM_ADDR": os.environ["API_UPSTREAM_ADDR"], + } + for k, v in subs.items(): + t = t.replace("{{%s}}" % k, v) + open("rendered/site.conf", "w").write(t) + PY + + - name: Sync static site (prerendered) + run: | + ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/bin/install -d -m 0755 '"${WEB_ROOT}" + rsync -az --delete --mkpath --rsync-path='sudo rsync' \ + --chown=root:root --chmod=D755,F644 \ + dist/ gitea_ci@"${WEB_HOST}":"${WEB_ROOT}/" + ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/sbin/restorecon -R '"${WEB_ROOT}" + + - name: Sync nginx vhost + apply SELinux + run: | + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + rendered/site.conf \ + gitea_ci@"${WEB_HOST}":/etc/nginx/conf.d/"${SERVER_NAME}".conf + ssh gitea_ci@"${WEB_HOST}" ' + set -euo pipefail + # nginx proxies /api/ to the api host across the WG mesh. + sudo /usr/sbin/setsebool -P httpd_can_network_connect on + if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then + sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"' + fi + sudo /usr/sbin/restorecon -R /etc/nginx/conf.d/'"${SERVER_NAME}"'.conf + sudo /usr/sbin/nginx -t + sudo /usr/bin/systemctl reload nginx' diff --git a/.gitea/workflows/refresh.yml b/.gitea/workflows/refresh.yml new file mode 100644 index 0000000..662b3a1 --- /dev/null +++ b/.gitea/workflows/refresh.yml @@ -0,0 +1,98 @@ +name: refresh + +# Daily re-bake of the prerendered site. The crawler-visible HTML is a static +# snapshot taken at build time; this job rebuilds it from the current gist (CV) +# and activity API and redeploys *only* the web tier — so edits propagate to +# what crawlers and AI screeners see without a code push and without bouncing +# the api/worker. Humans already get live data via post-hydration refetch. +# +# Uses the same gitea_ci + scoped-sudo path as deploy.yml's deploy-web job; +# see asset/sudoers.d/web-host.conf and script/infra-setup.sh. + +on: + schedule: + # 04:17 UTC daily — off-peak, arbitrary minute to avoid the top-of-hour herd. + - cron: '17 4 * * *' + workflow_dispatch: + +concurrency: + group: deploy # share the deploy lock so a refresh never races a push deploy + cancel-in-progress: false + +env: + WEB_HOST: oolon.kosherinata.internal + SERVER_NAME: rob.tn + WEB_ROOT: /var/www/rob.tn + API_PORT: "42424" + API_UPSTREAM_SCHEME: http + API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424 + VITE_API_BASE: https://rob.tn/api/v1 + DEPLOY_KEY: | + ${{ secrets.RSYNC_SSH_KEY }} + +jobs: + build-web: + name: Rebuild prerendered web + runs-on: rust # image has node; we only need the web toolchain here + steps: + - uses: actions/checkout@v4 + - name: Build web (vite client + prerender) + run: | + corepack enable + corepack prepare pnpm@10 --activate + pnpm --dir ui install --frozen-lockfile + pnpm --dir ui run build + - uses: actions/upload-artifact@v3 + with: { name: web-dist, path: ui/dist, retention-days: 1 } + + deploy-web: + name: Deploy refreshed web to oolon + needs: build-web + runs-on: fedora-43 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: { name: web-dist, path: dist } + + - name: SSH init + run: | + mkdir -p ~/.ssh + echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \ + gitea_ci@"${WEB_HOST}" 'hostname -f' + + - name: Render nginx vhost + run: | + mkdir -p rendered + python3 - <<'PY' + import os + t = open("asset/nginx/site.conf.tmpl").read() + for k in ("SERVER_NAME", "API_UPSTREAM_SCHEME", "API_UPSTREAM_ADDR"): + t = t.replace("{{%s}}" % k, os.environ[k]) + t = t.replace("{{DOCROOT}}", os.environ["WEB_ROOT"]) + open("rendered/site.conf", "w").write(t) + PY + + - name: Sync static site (prerendered) + run: | + ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/bin/install -d -m 0755 '"${WEB_ROOT}" + rsync -az --delete --mkpath --rsync-path='sudo rsync' \ + --chown=root:root --chmod=D755,F644 \ + dist/ gitea_ci@"${WEB_HOST}":"${WEB_ROOT}/" + ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/sbin/restorecon -R '"${WEB_ROOT}" + + - name: Sync nginx vhost + reload + run: | + rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \ + rendered/site.conf \ + gitea_ci@"${WEB_HOST}":/etc/nginx/conf.d/"${SERVER_NAME}".conf + ssh gitea_ci@"${WEB_HOST}" ' + set -euo pipefail + sudo /usr/sbin/setsebool -P httpd_can_network_connect on + if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then + sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"' + fi + sudo /usr/sbin/restorecon -R /etc/nginx/conf.d/'"${SERVER_NAME}"'.conf + sudo /usr/sbin/nginx -t + sudo /usr/bin/systemctl reload nginx' diff --git a/.gitignore b/.gitignore index 53ad8dc..a70f837 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # frontend /ui/node_modules /ui/dist +/ui/dist-server /ui/.vite *.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md index 2387046..8894a14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,9 +62,19 @@ cd ui pnpm install # install deps pnpm dev # dev server on :5173 (proxies /api/* to localhost:8080) pnpm lint # tsc --noEmit type-check -pnpm build # production build (tsc -b && vite build) +pnpm build # production build: client bundle, then prerender ``` +The build is three steps (see `ui/package.json`): `tsc -b` → `vite build` (client +SPA) → `pnpm run prerender` (an SSR build of `src/entry-server.tsx`, driven by +`run-prerender.mjs`, that bakes one static `index.html` per route into `ui/dist/`). +The prerender fetches data at build time from `VITE_API_BASE` (default +`https://rob.tn/api/v1`) and inlines the dehydrated react-query cache as +`window.__RQ_STATE__`; the client hydrates it and refetches live. So a plain +`curl` of any route returns full content (for crawlers / AI screeners), while the +browser keeps full interactivity. Date formatting in the shared tree is pinned to +UTC + explicit field widths so SSR and client hydration match byte-for-byte. + ## Database PostgreSQL with three migrations in `crates/moments-data/migrations/`. Two roles: `moments_rw` (worker, full access) and `moments_ro` (API, SELECT-only). @@ -77,4 +87,26 @@ Blog posts are markdown files with YAML frontmatter (`title`, `slug`, `date`; op ## Deployment -Production uses `./script/deploy.sh`. Services run under systemd with hardened units. Secrets resolved from `pass` store via template substitution. Nginx reverse-proxies `/api/` to the API host. +CI-driven via **Gitea Actions** (`.gitea/workflows/`), the source of infra truth +(hosts/ports/paths live in the workflow `env`, not a manifest): + +- `deploy.yml` — on push to `main` (or manual dispatch): lint/test gate, build the + api + worker as static musl binaries (pure-rustls, so no glibc skew) and the + prerendered web bundle, then deploy each component over SSH as the `gitea_ci` + user with scoped sudo (`asset/sudoers.d/`). Services run under systemd with + hardened units; the api/worker reach postgres over mTLS using the host cert. +- `refresh.yml` — daily `schedule:` (+ manual): rebuilds and redeploys only the + web tier, re-baking the prerendered crawler snapshot from the current gist (CV) + and activity API without bouncing the api/worker. + +One-time per-host provisioning (the `gitea_ci` user, its `authorized_keys`, the +scoped sudoers drop-in) is `script/infra-setup.sh`, run once per host by an +operator. Gitea repo secrets: `RSYNC_SSH_KEY`, `QUERY_GITHUB_TOKEN`, +`QUERY_GITEA_TOKEN` (the bare `GITHUB_TOKEN`/`GITEA_TOKEN` names are reserved by +Actions, so the worker poller's tokens use the `QUERY_` prefix). +Nginx reverse-proxies `/api/` to the API host and serves the per-route static +files via `try_files $uri $uri/ /index.html`. + +`./script/deploy.sh` is the legacy operator-driven path (workstation + `pass`); +it still works and the Gitea workflow supersedes it. Remove it once the workflow +is validated on the live hosts. diff --git a/asset/sudoers.d/api-host.conf b/asset/sudoers.d/api-host.conf new file mode 100644 index 0000000..d519e5e --- /dev/null +++ b/asset/sudoers.d/api-host.conf @@ -0,0 +1,35 @@ +# Scoped sudo for the gitea_ci deploy user on the moments-api host (nikola). +# Installed by script/infra-setup.sh as /etc/sudoers.d/moments_api_gitea_ci and +# verified with `visudo -cf`. Every rule is pinned to one literal destination; +# the `*` in an rsync rule matches rsync's --server argument vector, the trailing +# path is what actually bounds it. `:` and `=` are escaped (sudoers reserves them). + +# --- file pushes (rsync --rsync-path='sudo rsync') --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/moments.conf +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/moments-api +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/moments/api.env +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api-cert.path +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api-cert-reload.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/moments-api.xml + +# --- service account + directories --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g moments -m 0750 /etc/moments +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments + +# --- cert ACL, SELinux, firewalld --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/setfacl -m u\:moments\:r /etc/pki/tls/private/*.pem +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/moments-api /etc/moments /var/lib/moments +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp 42424 +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --query-service\=moments-api +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --add-service\=moments-api --permanent +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --add-service\=moments-api + +# --- service lifecycle --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now moments-api-cert.path +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable moments-api.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart moments-api.service diff --git a/asset/sudoers.d/web-host.conf b/asset/sudoers.d/web-host.conf new file mode 100644 index 0000000..e13a797 --- /dev/null +++ b/asset/sudoers.d/web-host.conf @@ -0,0 +1,20 @@ +# Scoped sudo for the gitea_ci deploy user on the web host (oolon). Installed by +# script/infra-setup.sh as /etc/sudoers.d/moments_web_gitea_ci and verified with +# `visudo -cf`. Used by both deploy.yml's deploy-web and refresh.yml. +# See api-host.conf for the rsync-rule convention. + +# --- docroot + static site --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -m 0755 /var/www/rob.tn +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/rob.tn/ +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /var/www/rob.tn + +# --- nginx vhost --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/nginx/conf.d/rob.tn.conf +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /etc/nginx/conf.d/rob.tn.conf + +# --- SELinux booleans/ports for the /api proxy + reload --- +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/setsebool -P httpd_can_network_connect on +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp 42424 +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx diff --git a/asset/sudoers.d/worker-host.conf b/asset/sudoers.d/worker-host.conf new file mode 100644 index 0000000..363c402 --- /dev/null +++ b/asset/sudoers.d/worker-host.conf @@ -0,0 +1,27 @@ +# Scoped sudo for the gitea_ci deploy user on the moments-worker host (frootmig). +# Installed by script/infra-setup.sh as /etc/sudoers.d/moments_worker_gitea_ci and +# verified with `visudo -cf`. See api-host.conf for the rsync-rule convention. + +# --- file pushes (rsync --rsync-path='sudo rsync') --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/moments.conf +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/moments-worker +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/moments/worker.env +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker-cert.path +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker-cert-reload.service + +# --- service account + directories --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g moments -m 0750 /etc/moments +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments + +# --- cert ACL, SELinux --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/setfacl -m u\:moments\:r /etc/pki/tls/private/*.pem +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/moments-worker /etc/moments /var/lib/moments + +# --- service lifecycle --- +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now moments-worker-cert.path +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable moments-worker.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart moments-worker.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet moments-worker.service diff --git a/script/infra-setup.sh b/script/infra-setup.sh new file mode 100755 index 0000000..a139ddb --- /dev/null +++ b/script/infra-setup.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# +# One-time setup for the gitea_ci deploy-user on every host that +# .gitea/workflows/{deploy,refresh}.yml targets: +# - create the gitea_ci system user (if missing) +# - install the runner's pubkey into ~gitea_ci/.ssh/authorized_keys +# - add gitea_ci to the systemd-journal group (so the workflow can capture +# `journalctl -u moments-*.service` without a sudoers entry) +# - install the host-appropriate /etc/sudoers.d/moments_*_gitea_ci drop-in, +# verified with `visudo -cf` so a typo can't lock the host out +# - check the postgres mTLS host cert the api/worker need already exists +# +# Run this from a workstation with ssh + sudo access to the hosts, once per +# host, before the deploy workflow can succeed. Idempotent — safe to re-run. +# Application config is NOT shipped here: the workflow renders /etc/moments/*.env +# from Gitea secrets on every deploy. +# +# Never suppresses errors; hosts that fail to provision are reported and skipped +# so one offline host doesn't block the rest. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_path="$(cd "${script_dir}/.." && pwd)" + +# host -> sudoers drop-in (the source of infra truth, mirroring the workflow env) +api_host="nikola.kosherinata.internal" +worker_host="frootmig.kosherinata.internal" +web_host="oolon.kosherinata.internal" + +api_sudoers="${repo_path}/asset/sudoers.d/api-host.conf" +worker_sudoers="${repo_path}/asset/sudoers.d/worker-host.conf" +web_sudoers="${repo_path}/asset/sudoers.d/web-host.conf" + +pubkey="${HOME}/.ssh/id_gitea_ci.pub" +if [[ ! -f "${pubkey}" ]]; then + echo "fatal: ${pubkey} not found" >&2 + echo " generate with: ssh-keygen -t ed25519 -f ${pubkey%.pub} -C gitea_ci" >&2 + echo " then add the matching private key as the RSYNC_SSH_KEY Gitea secret" >&2 + exit 1 +fi + +# Create gitea_ci, install its authorized_keys, and grant journal read access. +provision_user() { + local host="$1" + echo "==> ${host}: provisioning gitea_ci" + if ! ssh "${host}" ' + set -eu + if id -u gitea_ci >/dev/null 2>&1; then + echo " gitea_ci user already present" + else + sudo useradd --system --create-home \ + --home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci + echo " gitea_ci user created" + fi + # `install -o` does its own fresh user lookup, avoiding the brief NSS + # cache lag that makes `sudo -u gitea_ci` fail right after useradd. + sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh + sudo usermod -aG systemd-journal gitea_ci + '; then + echo " failed to provision gitea_ci — skipping ${host}" + return 1 + fi + + if rsync --archive --compress \ + --chown gitea_ci:gitea_ci --chmod 0600 \ + --rsync-path 'sudo rsync' \ + "${pubkey}" \ + "${host}:/var/lib/gitea_ci/.ssh/authorized_keys"; then + echo " authorized_keys synced" + else + echo " failed to sync authorized_keys to ${host}" + return 1 + fi +} + +# Install the sudoers drop-in and verify it parses, so a typo can't lock out. +install_sudoers() { + local host="$1" template="$2" name="$3" + local dest="/etc/sudoers.d/${name}" + echo "==> ${host}: installing ${dest}" + if ! rsync --archive --compress \ + --chown root:root --chmod 0440 \ + --rsync-path 'sudo rsync' \ + "${template}" \ + "${host}:${dest}"; then + echo " failed to sync ${template##*/}" + return 1 + fi + if ssh "${host}" "sudo visudo -cf ${dest}" >/dev/null; then + echo " installed and verified" + else + echo " WARNING: visudo rejected the installed file — review on ${host}" + return 1 + fi +} + +# The api/worker connect to postgres with mTLS using the host's own cert. The +# workflow only ACLs it to the moments user; it must already exist (provisioned +# by the host's PKI/step convention — see architecture/internal-tls.md). +check_pg_cert() { + local host="$1" + echo "==> ${host}: checking postgres mTLS host cert" + if ssh "${host}" ' + fqdn="$(hostname -f)" + test -f "/etc/pki/tls/private/${fqdn}.pem" && test -f "/etc/pki/tls/misc/${fqdn}.pem" + '; then + echo " host cert present" + else + echo " WARNING: /etc/pki/tls/{private,misc}/.pem missing on ${host}" + echo " the moments-{api,worker} service will fail to reach postgres until it exists" + fi +} + +setup_host() { + local host="$1" sudoers="$2" name="$3" + provision_user "${host}" && install_sudoers "${host}" "${sudoers}" "${name}" \ + || { echo " ${host}: setup incomplete"; return 1; } +} + +setup_host "${api_host}" "${api_sudoers}" moments_api_gitea_ci +check_pg_cert "${api_host}" +setup_host "${worker_host}" "${worker_sudoers}" moments_worker_gitea_ci +check_pg_cert "${worker_host}" +setup_host "${web_host}" "${web_sudoers}" moments_web_gitea_ci + +echo "==> done." +echo " Gitea repo secrets to set (Settings -> Actions -> Secrets):" +echo " RSYNC_SSH_KEY private key matching ${pubkey}" +echo " QUERY_GITHUB_TOKEN github api token for the worker poller" +echo " QUERY_GITEA_TOKEN git.lair.cafe api token for the worker poller" +echo " (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names — hence QUERY_.)" diff --git a/ui/package.json b/ui/package.json index 5be5d6c..bbaabc7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && VITE_API_BASE= vite build && pnpm run prerender", + "prerender": "VITE_API_BASE=\"${VITE_API_BASE:-https://rob.tn/api/v1}\" vite build --ssr src/entry-server.tsx --outDir dist-server --emptyOutDir && node run-prerender.mjs", "preview": "vite preview", "lint": "tsc --noEmit" }, diff --git a/ui/run-prerender.mjs b/ui/run-prerender.mjs new file mode 100644 index 0000000..05bc7d3 --- /dev/null +++ b/ui/run-prerender.mjs @@ -0,0 +1,127 @@ +// Drives the SSR bundle (built by `vite build --ssr`) to bake one static HTML +// file per route into dist/. Reads the client build's dist/index.html as the +// template, then for each route injects: route /description/og tags, a +// schema.org JSON-LD block, the server-rendered markup into #root, and the +// dehydrated react-query cache as window.__RQ_STATE__ for hydration. +// +// nginx serves these via `try_files $uri $uri/ /index.html`: /cv → cv/index.html, +// /blog/<slug> → blog/<slug>/index.html, etc., with unbaked routes (e.g. +// /activity/:timespan) SPA-falling-back to the home page and rendering client-side. + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const distDir = join(here, 'dist'); +const SITE_URL = 'https://rob.tn'; + +const { collectRoutes, renderRoute } = await import( + pathToFileURL(join(here, 'dist-server', 'entry-server.js')).href +); + +const escapeText = (s) => + s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); +const escapeAttr = (s) => escapeText(s).replace(/"/g, '"'); + +// JS line/paragraph separators are valid in JSON strings but illegal in a +// <script>; written here as escape sequences so the source file stays ASCII. +const JS_LINE_SEPARATORS = new RegExp('[\u2028\u2029]', 'g'); + +/** Safe for embedding JSON inside an inline <script>: neutralise `<` (so a + * `</script>` in the data can't close the tag) and the line separators. */ +const serialize = (value) => + JSON.stringify(value) + .replace(/</g, '\\u003c') + .replace(JS_LINE_SEPARATORS, (c) => '\\u' + c.charCodeAt(0).toString(16)); + +function replaceOrAppendHead(html, regex, tag) { + return regex.test(html) + ? html.replace(regex, tag) + : html.replace('</head>', ` ${tag}\n </head>`); +} + +function applyHead(html, head, url) { + let out = html.replace( + /<title>[\s\S]*?<\/title>/, + `<title>${escapeText(head.title)}`, + ); + + const desc = escapeAttr(head.description); + out = replaceOrAppendHead( + out, + /]*>/i, + ``, + ); + out = replaceOrAppendHead( + out, + /]*>/i, + ``, + ); + out = replaceOrAppendHead( + out, + /]*>/i, + ``, + ); + out = replaceOrAppendHead( + out, + /]*>/i, + ``, + ); + + const ld = ``; + return out.replace('', ` ${ld}\n `); +} + +function outPathFor(route) { + if (route === '/') return join(distDir, 'index.html'); + return join(distDir, route.replace(/^\//, ''), 'index.html'); +} + +const template = await readFile(join(distDir, 'index.html'), 'utf8'); +let routes = await collectRoutes(); + +// Optional filter args: `node run-prerender.mjs /activity /cv` bakes only the +// routes whose path starts with one of the given prefixes (useful for quick +// iteration). With no args, every route is baked. +const filters = process.argv.slice(2); +if (filters.length > 0) { + routes = routes.filter((r) => filters.some((f) => r === f || r.startsWith(f))); +} + +let ok = 0; +let failed = 0; + +for (const route of routes) { + const url = route === '/' ? `${SITE_URL}/` : `${SITE_URL}${route}`; + try { + const { html, state, head } = await renderRoute(route); + let page = applyHead(template, head, url); + page = page.replace( + '
', + `
${html}
\n `, + ); + const outPath = outPathFor(route); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, page); + ok += 1; + console.log(`prerendered ${route}`); + } catch (err) { + // Don't fail the whole build for one route — write the bare template so the + // route still resolves and hydrates client-side, and surface the error. + failed += 1; + console.error(`prerender FAILED for ${route}: ${err?.stack || err}`); + const outPath = outPathFor(route); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, template); + } +} + +console.log(`prerender complete: ${ok} ok, ${failed} failed, ${routes.length} routes`); +// A handful of failed routes still ship a working client-rendered fallback, so +// don't block the deploy for those. Only fail the build if nothing prerendered +// at all (e.g. the API was unreachable), which signals a genuinely broken build. +if (ok === 0) { + console.error('prerender produced no pages — failing the build'); + process.exitCode = 1; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 7375e2a..af64f6c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,9 +1,8 @@ import { Routes, Route } from 'react-router-dom'; -import 'bootstrap/dist/css/bootstrap.min.css'; -import 'rc-slider/assets/index.css'; -import 'react-vertical-timeline-component/style.min.css'; -import './App.css'; +// CSS imports live in the client entry (`main.tsx`), not here, so this module +// stays importable under Node during the prerender build (renderToString can't +// process CSS imports). import { Layout } from './components/Layout'; import { DashPage } from './pages/DashPage'; diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index a7a8240..c03b5ca 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -91,7 +91,10 @@ export interface EventQuery { limit?: number; } -const API_BASE = '/api/v1'; +// Browser builds use a same-origin relative base (nginx proxies `/api/`). +// The prerender build runs under Node, which has no relative-URL origin, so it +// sets VITE_API_BASE to the absolute public API. See `vite-env.d.ts`. +const API_BASE = import.meta.env.VITE_API_BASE || '/api/v1'; /** Decode base64 content as UTF-8 (atob only handles Latin-1). */ function decodeBase64Utf8(b64: string): string { diff --git a/ui/src/api/cv.ts b/ui/src/api/cv.ts index 50c5a7a..e01fdc4 100644 --- a/ui/src/api/cv.ts +++ b/ui/src/api/cv.ts @@ -57,13 +57,24 @@ export async function fetchCv(): Promise { } const gist = (await resp.json()) as GistResponse; - const cfgFile = gist.files[CONFIG_FILENAME]; + // Drop inlined `content` for non-text files (the photo, company logos, and + // pdf/docx/odt exports). The CV only ever references those by their raw gist + // URL — keeping the base64 here would bloat every render, and balloons the + // prerendered /cv page by ~1 MB of uncompressible data. + const files: Record = {}; + for (const [name, file] of Object.entries(gist.files)) { + const isText = + file.type.startsWith('text/') || file.type === 'application/json'; + files[name] = isText ? file : { ...file, content: '' }; + } + + const cfgFile = files[CONFIG_FILENAME]; if (!cfgFile) { throw new Error(`gist: missing ${CONFIG_FILENAME}`); } const config = JSON.parse(cfgFile.content) as CvConfig; - return { config, files: gist.files }; + return { config, files }; } // Pick out the gist files whose names start with the given prefix, applying diff --git a/ui/src/components/Filters.tsx b/ui/src/components/Filters.tsx index 00147e1..217e204 100644 --- a/ui/src/components/Filters.tsx +++ b/ui/src/components/Filters.tsx @@ -88,11 +88,14 @@ export function Filters({ } function formatDate(ts: number): string { + // Format in UTC so the prerendered (Node) and client (browser) output match + // regardless of the viewer's timezone — otherwise hydration mismatches. return new Date(ts) .toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric', + timeZone: 'UTC', }) .toLowerCase(); } diff --git a/ui/src/components/TimelineEntry.tsx b/ui/src/components/TimelineEntry.tsx index f355d7f..85f4e5f 100644 --- a/ui/src/components/TimelineEntry.tsx +++ b/ui/src/components/TimelineEntry.tsx @@ -74,6 +74,9 @@ function renderSegment(seg: TitleSegment, i: number) { } function formatDate(iso: string): string { + // Format in UTC so the prerendered (Node) and client (browser) renders are + // byte-identical regardless of the viewer's timezone — otherwise the dates + // mismatch and React rejects the hydration (error #418). const d = new Date(iso); const date = d .toLocaleDateString('en-GB', { @@ -81,10 +84,21 @@ function formatDate(iso: string): string { year: 'numeric', month: 'long', day: 'numeric', + timeZone: 'UTC', }) .toLowerCase(); const time = d - .toLocaleTimeString('en-GB', { timeZoneName: 'short' }) + .toLocaleTimeString('en-GB', { + timeZone: 'UTC', + // Explicit 2-digit fields + 24h cycle: en-GB's *default* hour rendering + // isn't padded identically across JS engines (Node emits "9:53", Firefox + // "09:53"), which breaks hydration. Pinning the fields makes it match. + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + timeZoneName: 'short', + }) .toLowerCase(); return `${date} — ${time}`; } diff --git a/ui/src/entry-server.tsx b/ui/src/entry-server.tsx new file mode 100644 index 0000000..ac4f11e --- /dev/null +++ b/ui/src/entry-server.tsx @@ -0,0 +1,61 @@ +// Server entry for the prerender. Built once with `vite build --ssr` and then +// driven by `run-prerender.mjs` under Node. For each route it prefetches the +// route's data, renders the real React tree to an HTML string, and returns the +// markup alongside the dehydrated react-query cache and the route's +// metadata. No browser globals are touched on the render path. + +import { StrictMode } from 'react'; +import { renderToString } from 'react-dom/server'; +import { StaticRouter } from 'react-router-dom'; +import { + QueryClient, + QueryClientProvider, + dehydrate, + type DehydratedState, +} from '@tanstack/react-query'; + +import App from './App'; +import { prefetchRoute } from './prerender/prefetch'; +import { headForRoute, type HeadMeta } from './prerender/meta'; + +export { collectRoutes } from './prerender/routes'; +export type { HeadMeta }; + +export interface RenderResult { + html: string; + state: DehydratedState; + head: HeadMeta; +} + +export async function renderRoute(path: string): Promise { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // Treat prefetched data as fresh + non-collectable so it survives + // until dehydration and `useQuery` reads it synchronously during + // renderToString instead of trying to refetch. + staleTime: Infinity, + gcTime: Infinity, + }, + }, + }); + + await prefetchRoute(queryClient, path); + + const html = renderToString( + + + + + + + , + ); + + return { + html, + state: dehydrate(queryClient), + head: headForRoute(path, queryClient), + }; +} diff --git a/ui/src/lib/ranges.ts b/ui/src/lib/ranges.ts new file mode 100644 index 0000000..c15fe8c --- /dev/null +++ b/ui/src/lib/ranges.ts @@ -0,0 +1,71 @@ +// Date-range helpers shared by the dashboard/timeline components and the +// build-time prerender prefetch. Centralising the window maths keeps the +// react-query *keys* identical between the SSR snapshot and the client's +// first render, so hydration reuses the inlined cache instead of refetching. +// +// All windows are stamped to the UTC day (YYYY-MM-DD). Same-day loads hydrate +// from the baked snapshot; loads on a later day compute a different key and +// refetch live — the intended freshness tradeoff for the activity data. + +import type { SourceSummary } from '../api/client'; + +export function fmtDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** Contribution graph + language stream: trailing 365 days. */ +export function lastYearRange(now: Date = new Date()) { + const to = new Date(now); + const from = new Date(to); + from.setFullYear(from.getFullYear() - 1); + return { from, to, fromStr: fmtDate(from), toStr: fmtDate(to) }; +} + +/** Earliest activity date across all sources, or null if none reported. */ +export function earliestFrom(sources: SourceSummary[]): Date | null { + const dates = sources + .map((s) => s.earliest) + .filter((d): d is string => d != null) + .map((d) => new Date(d)); + return dates.length > 0 + ? new Date(Math.min(...dates.map((d) => d.getTime()))) + : null; +} + +/** All-time graphs/stats: earliest source date (fallback 5y) through now. */ +export function allTimeRange(earliest: Date | null, now: Date = new Date()) { + const to = new Date(now); + const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1); + return { from, to, fromStr: fmtDate(from), toStr: fmtDate(to) }; +} + +/** Timeline slider upper bound, bucketed to end-of-day (UTC) so the SSR and + * client renders agree on the slider scale and labels (no hydration mismatch). */ +export function endOfTodayMs(now: Date = new Date()): number { + const d = new Date(now); + d.setUTCHours(23, 59, 59, 999); + return d.getTime(); +} + +/** Timeline default window: trailing 30 days, with both bounds bucketed to the + * UTC day so the slider's millisecond values are identical across the + * prerender and the client's first render. */ +export function defaultActivityRange(now: number = Date.now()) { + const to = endOfTodayMs(new Date(now)); + const from = to - 30 * 24 * 60 * 60 * 1000; + return { + from, + to, + fromStr: fmtDate(new Date(from)), + toStr: fmtDate(new Date(to)), + }; +} + +/** The hour-of-day histogram buckets in the viewer's timezone. */ +export function resolvedTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch { + return 'UTC'; + } +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index d198950..333ffc5 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,9 +1,30 @@ import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; +import { createRoot, hydrateRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + QueryClient, + QueryClientProvider, + hydrate, + type DehydratedState, +} from '@tanstack/react-query'; import App from './App'; +// Global stylesheets are imported here, in the client entry only — keeping +// them out of the shared component tree lets the prerender build import that +// tree under Node (where `import './x.css'` would otherwise fail). +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'rc-slider/assets/index.css'; +import 'react-vertical-timeline-component/style.min.css'; +import './App.css'; +import './pages/CvPage.css'; + +declare global { + interface Window { + /** Dehydrated react-query cache inlined by the prerender step. */ + __RQ_STATE__?: DehydratedState; + } +} + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -13,12 +34,29 @@ const queryClient = new QueryClient({ }, }); -createRoot(document.getElementById('root')!).render( +// Seed the cache from the prerendered snapshot so the first client render +// matches the server HTML (no loading flash) and refetches only when stale. +const dehydratedState = window.__RQ_STATE__; +if (dehydratedState) { + hydrate(queryClient, dehydratedState); +} + +const tree = ( - , + ); + +const container = document.getElementById('root')!; + +// Hydrate the prerendered markup when present; fall back to a fresh render +// (e.g. SPA-fallback routes that weren't prerendered, or a dev server). +if (dehydratedState && container.firstChild) { + hydrateRoot(container, tree); +} else { + createRoot(container).render(tree); +} diff --git a/ui/src/pages/BlogIndexPage.tsx b/ui/src/pages/BlogIndexPage.tsx index 142cb9a..e89b86b 100644 --- a/ui/src/pages/BlogIndexPage.tsx +++ b/ui/src/pages/BlogIndexPage.tsx @@ -38,11 +38,14 @@ export function BlogIndexPage() { } export function formatDate(iso: string): string { + // UTC-fixed so prerender (Node) and client (browser) agree — see formatDate + // in TimelineEntry for the hydration rationale. return new Date(iso) .toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric', + timeZone: 'UTC', }) .toLowerCase(); } diff --git a/ui/src/pages/CvPage.tsx b/ui/src/pages/CvPage.tsx index a169332..ae8ebe6 100644 --- a/ui/src/pages/CvPage.tsx +++ b/ui/src/pages/CvPage.tsx @@ -10,7 +10,8 @@ import { fetchCv, filesForSection } from '../api/cv'; import { CvHeader } from '../components/cv/CvHeader'; import { CvSection } from '../components/cv/CvSection'; import { CvTimeline } from '../components/cv/CvTimeline'; -import './CvPage.css'; +// CvPage.css is imported from the client entry (`main.tsx`) so this page stays +// importable under Node during the prerender build. export function CvPage() { const { hash } = useLocation(); diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx index 72b13b8..bc5d62d 100644 --- a/ui/src/pages/DashPage.tsx +++ b/ui/src/pages/DashPage.tsx @@ -113,7 +113,9 @@ function forgeIcon(source: string): string { function formatRange(first: string | null, last: string | null): string { const fmt = (iso: string) => - new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase(); + new Date(iso) + .toLocaleDateString('en-GB', { month: 'short', year: 'numeric', timeZone: 'UTC' }) + .toLowerCase(); if (first && last) return `${fmt(first)} — ${fmt(last)}`; if (last) return fmt(last); return ''; diff --git a/ui/src/pages/TimelineHome.tsx b/ui/src/pages/TimelineHome.tsx index 248ec17..3f9c898 100644 --- a/ui/src/pages/TimelineHome.tsx +++ b/ui/src/pages/TimelineHome.tsx @@ -8,9 +8,10 @@ import { VerticalTimeline } from 'react-vertical-timeline-component'; import { fetchDailyCounts, fetchEvents, fetchSources, type Source } from '../api/client'; import { Filters } from '../components/Filters'; import { TimelineEntry } from '../components/TimelineEntry'; +import { defaultActivityRange, endOfTodayMs, fmtDate } from '../lib/ranges'; const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime(); -const RANGE_MAX = Date.now(); +const RANGE_MAX = endOfTodayMs(); function parseDate(s: string): number { // Accept YYYY-MM-DD or full ISO datetime @@ -51,9 +52,8 @@ export function TimelineHome() { const [rangeValue, setRangeValue] = useState<[number, number]>(() => { const parsed = parseTimespan(timespan); if (parsed) return parsed; - const now = Date.now(); - const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; - return [thirtyDaysAgo, now]; + const { from, to } = defaultActivityRange(); + return [from, to]; }); const [limit, setLimit] = useState(100); @@ -69,8 +69,13 @@ export function TimelineHome() { [enabledSources], ); + // Day-stamped keys (rather than raw millisecond bounds) so the prerendered + // snapshot and the client's first render agree on the same UTC day. + const fromStr = fmtDate(new Date(rangeValue[0])); + const toStr = fmtDate(new Date(rangeValue[1])); + const eventsQ = useQuery({ - queryKey: ['events', rangeValue, activeSources, limit], + queryKey: ['events', fromStr, toStr, activeSources, limit], queryFn: () => fetchEvents({ from: new Date(rangeValue[0]), @@ -83,8 +88,6 @@ export function TimelineHome() { const events = eventsQ.data ?? []; - const fromStr = new Date(rangeValue[0]).toISOString().slice(0, 10); - const toStr = new Date(rangeValue[1]).toISOString().slice(0, 10); const dailyQ = useQuery({ queryKey: ['daily-counts', fromStr, toStr], queryFn: () => fetchDailyCounts(fromStr, toStr), diff --git a/ui/src/prerender/meta.ts b/ui/src/prerender/meta.ts new file mode 100644 index 0000000..863cb5d --- /dev/null +++ b/ui/src/prerender/meta.ts @@ -0,0 +1,133 @@ +// Per-route metadata baked into the prerendered HTML: a real title and +// description for each route, plus a schema.org Person + Occupation JSON-LD +// block so crawlers and AI screening tools get structured identity data even +// without running JS. + +import type { QueryClient } from '@tanstack/react-query'; + +import type { BlogPost, ProjectSummary } from '../api/client'; + +export interface HeadMeta { + title: string; + description: string; + /** schema.org JSON-LD object, serialised into a