Files
hortusfox-web-nixos/CLAUDE.md
T
awkawbandClaude Sonnet 5 d88f6f312d Add adminAccount.username, decoupled from the seeded admin's hardcoded name
The seeded admin account's UserModel "name" was hardcoded to "admin".
Make it a separate, overridable option (still defaulting to "admin"
for compatibility) -- it's purely a display name, since HortusFox logs
in by email rather than this field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:52:33 -04:00

58 lines
16 KiB
Markdown

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A Nix flake that packages [HortusFox](https://github.com/danielbrendel/hortusfox-web) (a self-hosted, PHP/Laravel-style collaborative plant-management app) from source, and provides a NixOS module to deploy it as a full service (mariadb + phpfpm + nginx/caddy + ACME + cron timers).
- `flake.nix` — flake entry point: exposes `packages.hortusfox-web`, `overlays.default`, `nixosModules.default`, `devShells.default`, `checks.<system>.{build,vm-test,vm-test-caddy}`, and a throwaway `nixosConfigurations.test` VM for manual testing. Only `packages`/`devShells`/`checks` are produced per-system via `utils.lib.eachSystem`; `overlays.default`, `nixosModules.default`, and `nixosConfigurations.test` are merged in afterwards with `//` since those output types are keyed by name, not by system — don't move them back inside the `eachSystem` callback.
- `package.nix` — the derivation. Uses `php.buildComposerProject` to fetch `danielbrendel/hortusfox-web` from GitHub and vendor its Composer deps. Installs to `$out/share/php/hortusfox-web/`. Both `src.hash` and `vendorHash` are fixed-output hashes pinned to a specific upstream `version`/`rev` — bumping the app version means updating both.
- `nixos-module/default.nix` — the `services.hortusfox-web` NixOS module (see Architecture below).
- `tests/default.nix` — a minimal NixOS system config used as the `nixosConfigurations.test` VM (built/booted via `just build-vm`/`just boot-vm`), plus a secrets file (`tests/test.env`) and an admin password file (`tests/hortusfox_web_test_password`, currently `password`) consumed by the module.
- `tests/vm-test.nix` — `{ pkgs, nixosModule, proxyServer }: pkgs.testers.nixosTest { ... }`, the actual automated boot test. `flake.nix` calls it once per proxy backend (`checks.vm-test` for nginx, `checks.vm-test-caddy` for caddy) rather than inlining the test twice.
- `justfile` — VM dev-loop wrapper around `nix build`/QEMU.
There is no application source code in this repo — HortusFox itself is fetched from upstream at build time. This repo is purely packaging/deployment glue.
## Commands
```bash
just build-vm # nix build the test VM (nixosConfigurations.test), via `nom build`
just boot-vm # run the built VM in-terminal (QEMU, serial console, host 127.0.0.1:8080 -> guest 80)
just clean # kill this project's own qemu instance, remove ./result and ./nixos.qcow2
nix build .#hortusfox-web # build just the HortusFox package
nix build # same (default package)
nix develop # dev shell: matching PHP+extensions build, composer, just, nix-output-monitor
nix flake show # list outputs
nix flake check # evaluate all outputs; builds the package + runs both nixosTest VM checks
nix flake check -L # same, with full build/VM logs streamed (slow: boots two real VMs)
```
`justfile`'s QEMU networking is bound to `127.0.0.1` explicitly (`hostfwd=tcp:127.0.0.1:8080-:80`) — a bare `hostfwd=tcp::8080-:80` (empty host address) binds `0.0.0.0`, exposing this throwaway VM (root password `toor`, firewall disabled, seeded test credentials) to the whole network. Likewise `just clean` kills only this project's own qemu process (`pkill -f "$(pwd)/nixos.qcow2"`), not `pkill qemu` generally, which would kill any unrelated QEMU VM on the host.
`checks.<system>.vm-test`/`vm-test-caddy` (`tests/vm-test.nix`) boot the module with `proxy.ssl = false` (no ACME/internet in the sandbox), `settings.APP_DEBUG = true` (required for migrations, see below), and a real `adminAccount.passwordFile`, then assert: the backend's own unit + phpfpm + mysql all come up; port 80 responds; the response contains no `"Asatru PHP - Exception"` marker (Asatru renders uncaught exceptions as HTML without failing the HTTP status — see below); a real static asset (`/img/pattern.jpg`) is served with no `Permission denied` in the proxy's own log; and — the strongest assertion — a full cookie-jar login as the seeded admin actually lands away from `/auth` rather than bouncing back to it. Each of these exists because a weaker check was previously fooled by a real bug during development (see the architecture notes below); don't weaken or remove any of them without understanding why. There's no other test suite; there's no lint/format command configured. `just --list` shows the same recipes as above.
## Architecture (NixOS module)
The module (`nixos-module/default.nix`, `config.services.hortusfox-web`) wires HortusFox into a full host:
- **Read-only store vs. writable state, reconciled at boot.** The Composer-built package (`cfg.package`, defaulting to `pkgs.callPackage ../package.nix { }`) lives read-only in the Nix store (`appRoot`). A `hortusfox-setup` oneshot systemd service (before phpfpm starts) mirrors it into a runtime tree (`runDir = /run/hortusfox/www`, tmpfs) via a real, full `cp -r` — **not** `cp -rs` (per-file symlinks back into the store). This was a real, previously-shipped bug: PHP resolves `__DIR__`/`__FILE__` *through* a symlink to its real target, so a symlinked file under `runDir` reports its own location as the read-only store, not `runDir`. HortusFox computes its whole app root (and from there, `.env` discovery) via `__DIR__`-relative paths, so every symlinked file silently looked for config in the store instead of `runDir` — breaking DB/session config on every real web request while looking fine on `curl`. Verified by a minimal local repro before fixing; don't reintroduce per-file symlinking here. The tradeoff is real: this duplicates the app into tmpfs on every boot instead of deduping via the store, which is an acceptable cost for a small PHP app.
- **`cp` carries the store's read-only mode onto the copy.** Every directory `cp -r` creates inherits the store's `555` (no write) mode, so the script reopens them for writing (`find "${runDir}" -type d -exec chmod u+w {} +`, directories only, never individual files) before it can `rm`/relink anything under them.
- **Writable subdirs are seeded, not just replaced.** `cache`, `app/logs`, `public/img`, `public/attachments`, `public/backup`, `public/themes` get swapped for symlinks into persistent state (`stateDir = /var/lib/hortusfox`). Two of these (`public/img`, `public/themes`) ship real default assets in the package itself (login background, default theme) — naively replacing them with an empty persistent dir on first boot silently deletes those defaults and breaks page rendering (`Exception: Resource "img/pattern.jpg" not found`). The fix: before symlinking, `cp -rn` (no-clobber) the package's copy of each writable dir into `stateDir/<dir>` first, so shipped defaults get seeded in (and any *new* ones added by a future upstream bump get picked up too) without ever touching a file the user/app has already written.
- **Nested writable dirs need their own tmpfiles rule for the parent, too.** `systemd.tmpfiles.rules` declares a `d` line per entry in `writableDirs`, but `app/logs` and the four `public/*` entries are nested under `app`/`public`, which have no rule of their own. `systemd-tmpfiles` auto-vivifies a missing parent for a nested `d` line, but does so with default `root:root 0755` ownership and does **not** reliably go on to create the requested leaf — so `app/logs` (and all of `public/*`) silently never got created, and the hortusfox-setup service's own `mkdir -p "${stateDir}/${d}"` would fail with `Permission denied` trying to create the leaf inside a root-owned, not-writable-by-`cfg.user` parent. Fixed by computing `writableDirParents` (the unique set of intermediate dirs, e.g. `app`, `public`) and giving each its own explicit tmpfiles `d` rule, ordered before the leaves. If you add a new *nested* entry to `writableDirs`, make sure its parent is covered by this too.
- **`RuntimeDirectoryMode` and `stateDir`'s tmpfiles rules must both stay world-readable (`0755`).** nginx/caddy run as their own user, not `cfg.user`, and need to `stat`/read everything under `runDir` *and* into `stateDir`'s writable subdirs (`public/img` etc., reached through symlinks from `runDir`) to serve `public/` directly. At `0750` the proxy can't even traverse the directory, `tryFiles`/`file_server` stats fail with `Permission denied`, and — this is the trap — the site can still *appear* to work, because PHP-FPM (running as `cfg.user`) serves pages fine over fastcgi regardless of the proxy's own filesystem access; only requests for *static* files (not routed through PHP) actually exercise this path. A plain `curl` on `/` won't catch it; the VM tests fetch a real static asset and grep the proxy's own log for `Permission denied` specifically because of it. Nothing secret is exposed by the world-readable mode: `.env` is a symlink to `stateDir/.env`, which stays `600` independently.
- **`.env` rendering.** A base attrset of env vars (`envVars`) is merged with the freeform `cfg.settings` attrset (`envVars // cfg.settings`, so `settings` always wins) and serialized to KEY=VALUE lines via `pkgs.writeText`. `envVars` isn't just our curated options (`domain`/`timezone`/`database.name`/`smtp.*`) — it mirrors the *entire* default set from upstream's `.env.example` (app metadata, session duration, photo-resize factors, `SMTP_AUTH`, `APP_BASEDIR`, ...). Leaving any of these out isn't cosmetic: several app code paths read `$_ENV[...]` directly with no `isset()` guard (`APP_BASEDIR` in particular), so an omitted key produces a `PHP Warning: Undefined array key` on *every single request*, not just a missing feature. `APP_VERSION` derives from `package.version` rather than being duplicated by hand, so it can't drift from `package.nix` on a version bump. Secrets are handled separately: if `cfg.secretFile` is set, its contents are `cat`'d verbatim onto the end of the rendered file at runtime — it is *not* parsed or filtered, so it can carry arbitrary keys (`APP_KEY`, `SMTP_PASSWORD`, custom API keys, …). The rendered file lands in `stateDir/.env`, then is symlinked into `runDir`.
- **`settings.APP_DEBUG` matters more than it looks.** Upstream's `asatru` CLI (`app root`/`asatru` file) refuses to run at all unless `APP_DEBUG` is true — it just prints a warning and exits 0. That means the `hortusfox-setup` service's `php asatru migrate:fresh`/`migrate:upgrade` calls are silent no-ops unless `settings.APP_DEBUG = true` (or it's set via `secretFile`). The module's own default is `APP_DEBUG = false`, so **migrations do not run out of the box** — this is why `tests/default.nix` and both `vm-test`s explicitly set `settings.APP_DEBUG = true`.
- **First-boot vs. upgrade migrations.** `stateDir/.installed` is the marker: absent → `php asatru migrate:fresh` (destructive schema create) runs and then creates the marker; present → `php asatru migrate:upgrade || true` runs instead. This is the guard against re-running a destructive migration against real data — don't remove it without understanding why it's there.
- **`adminAccount.passwordFile` actually seeds an admin account.** This was previously a documented no-op; it isn't anymore, because there is no other way to log in at all. `migrate:fresh` creates schema only, no rows. HortusFox has no CLI/seed hook of its own, and its only "create user" flow lives in the admin panel — which requires already being logged in as an admin. So on first boot only (same `.installed` guard as migrations), if this is set, the setup script bcrypt-hashes the file's contents via `php -r '...password_hash(...)...'` (matching `UserModel`'s own hashing) and `INSERT`s a row directly into the `UserModel` table (name `adminAccount.username`, default `"admin"`, purely a display name since HortusFox logs in by email; email `adminAccount.email` — defaults to `admin@<domain>` but is independently overridable, since that default isn't necessarily a mailbox anyone controls and HortusFox's own "Restore password" flow emails it — admin flag set), bypassing the app's normal create-then-promote two-step flow since neither step is reachable yet. Both `vm-test`s exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from `/auth` and checking the final URL isn't `/auth` again — page content alone isn't reliable here, since the dashboard's own (hidden) change-password form makes "does the response contain a password field" a false-positive-prone check.
- **Serving.** `proxy.server` selects the reverse-proxy backend: `"nginx"` (default) or `"caddy"`, each gated by `lib.mkIf (cfg.proxy.server == "...")`. `proxy.ssl` (default `true`) toggles HTTPS/ACME on the vhost — nginx via `forceSSL`/`enableACME`, caddy by prefixing the site address with `http://` (which disables its automatic HTTPS for that vhost specifically) — set it to `false` for local/test deployments with no real domain or internet access to reach Let's Encrypt (both `vm-test`s and `tests/default.nix` do this). `services.phpfpm.pools.hortusfox` runs as `cfg.user`/`cfg.group` regardless of proxy backend, but its fastcgi socket's `listen.owner`/`listen.group` must match whichever proxy is actually enabled (`config.services.caddy.user`/`.group` vs `config.services.nginx.user`/`.group`) — this was hardcoded to nginx before caddy support existed. nginx uses explicit `tryFiles`/location blocks (an Asatru-framework-style rewrite-to-`index.php` rule, mirroring upstream's `.htaccess`); caddy's `php_fastcgi` directive covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. When `proxy.ssl = true`, the deploying host config must set nginx's `security.acme.{acceptTerms,defaults.email}` or caddy's `services.caddy.email` globally (see comment at the bottom of the module).
- **`port` also has to override `SERVER_PORT` at the fastcgi layer, not just `APP_URL`.** Asatru's own `url()`/`asset()` helpers (`vendor/danielbrendel/asatru-php-framework/src/helper.php`) never read `APP_URL` at all — they build every absolute URL straight from `$_SERVER['SERVER_NAME']`/`SERVER_PORT`. Nginx's/Caddy's default fastcgi env populates `SERVER_PORT` from the port the proxy itself is listening on (e.g. the QEMU test VM's unforwarded guest port 80), not an externally-reachable/forwarded one (e.g. the hostfwd'd 8080) — so with only `APP_URL` fixed, page HTML still loaded fine (that request itself hits the forwarded port directly) but every asset/CSS/JS URL the app *generates* silently dropped the port, breaking every static asset (`net::ERR_CONNECTION_REFUSED` in the browser, since nothing listens on host port 80). Fixed by explicitly overriding the `SERVER_PORT` fastcgi param/env var to `cfg.port` whenever it's set — nginx via an extra `fastcgi_param SERVER_PORT "...";` line after `include fastcgi_params` (later same-name `fastcgi_param` wins), caddy via `php_fastcgi`'s `env SERVER_PORT ...` sub-directive. Verified by booting `nixosConfigurations.test` manually and confirming assets load over the forwarded port.
- **Cron.** HortusFox's cron endpoints (task overdue/tomorrow/recurring reminders, calendar reminders, auto-backup) are driven by `curl`ing the app's own HTTP cron routes from systemd timers (`hortusfox-cron-<name>` service+timer pairs), rather than an in-process scheduler. These live in the *same* `systemd.services` attrset as `hortusfox-setup`/`phpfpm-hortusfox` (merged in with `//`, generated via `lib.mapAttrs'` over `cronjobs`) — keep it that way; splitting the cron services into a second top-level `systemd.services = ...;` binding is a Nix duplicate-attribute error (partial-path assignments like `systemd.services.hortusfox-setup = ...` can't coexist with a later full `systemd.services = ...` reassignment in the same module).
## Current state / known issues
- `nix flake check` passes cleanly for all outputs (`packages`, `checks.build`, `checks.vm-test`, `checks.vm-test-caddy`, `overlays`, `nixosModules`, `nixosConfigurations.test`), and the VM actually serves a working, loggable-into site end-to-end for both proxy backends (verified via the automated `vm-test`s and by booting `nixosConfigurations.test` manually and logging in over HTTP with `tests/hortusfox_web_test_password`). Note `tests/default.nix` carries a `fileSystems."/"`/`boot.loader.grub.devices`/`security.acme` stanza purely so `config.system.build.toplevel` (what `nix flake check` builds) evaluates — the VM is actually built and run via `.config.system.build.vm` (see `justfile`'s `build-vm`/`boot-vm`), which doesn't use any of that.
- Upstream's own templates still emit a `PHP Deprecated: htmlspecialchars(): Passing null to parameter #1` notice on some pages (PHP 8.1+ deprecation for `htmlspecialchars(null)`). Harmless and not something this repo can fix — it's app code, not packaging.