{ config, lib, pkgs, ... }: let cfg = config.services."hortusfox-web"; # The app's own canonical URL (used for login/session redirects and cron # requests) must track proxy.ssl, or a plain-HTTP deployment (proxy.ssl = # false, e.g. the test VM) redirects users to an https:// URL nothing is # listening on. scheme = if cfg.proxy.ssl then "https" else "http"; portSuffix = if cfg.port == null then "" else ":${toString cfg.port}"; appUrl = "${scheme}://${cfg.domain}${portSuffix}"; # PHP with the extensions HortusFox needs (GD for photo resize, pdo_mysql, etc.) phpPackage = cfg.phpPackage.buildEnv { extensions = ({ enabled, all }: enabled ++ (with all; [ pdo_mysql mysqli gd exif fileinfo mbstring curl zip openssl ])); }; package = cfg.package; appRoot = "${package}/share/php/hortusfox-web"; runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs) stateDir = "/var/lib/hortusfox"; # persistent writable data # Writable subdirs that must survive reboots. writableDirs = [ "cache" "app/logs" "public/img" "public/attachments" "public/backup" "public/themes" ]; # Intermediate parent dirs of the above (e.g. "app", "public") that need # their own explicit tmpfiles rule too: systemd-tmpfiles auto-vivifies a # missing parent when processing a nested `d` line, but does so with # default (root:root 0755) ownership and does NOT reliably go on to # create the requested leaf itself -- so a nested writableDirs entry # whose parent has no rule of its own silently never gets created. writableDirParents = lib.unique (builtins.filter (p: p != null) (map (d: let parts = lib.splitString "/" d; in if builtins.length parts > 1 then lib.concatStringsSep "/" (lib.init parts) else null) writableDirs)); # Render one KEY=VALUE .env line. Booleans/ints/floats are left bare so # Asatru's datatype auto-detection picks them up as such rather than string. mkEnvLine = name: value: let rendered = if builtins.isBool value then (if value then "true" else "false") else if builtins.isInt value then toString value else if builtins.isFloat value then toString value else ''"${toString value}"''; in "${name}=${rendered}"; # Base .env content; `settings` can override or extend any of this. Beyond # our own curated options (domain/timezone/db/smtp), this also seeds every # other key from upstream's .env.example that app code reads directly from # $_ENV without an isset() guard -- e.g. APP_BASEDIR, omitted here before, # produced a "PHP Warning: Undefined array key" on every single request. envVars = { APP_NAME = "HortusFox"; APP_VERSION = package.version or "unknown"; APP_AUTHOR = "Daniel Brendel"; APP_CONTACT = "dbrendel1988@gmail.com"; APP_DEBUG = false; APP_BASEDIR = ""; APP_LANG = "en"; APP_WORKSPACE = "My workspace"; APP_OVERDUETASK_HOURS = 10; APP_CRONJOB_MAILLIMIT = 5; APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web"; APP_SERVICE_URL = "https://www.hortusfox.com"; APP_GITHUB_SPONSOR = "https://github.com/sponsors/danielbrendel"; APP_DONATION_KOFI = "https://ko-fi.com/danielbrendel"; APP_URL = appUrl; APP_TIMEZONE = cfg.timezone; SESSION_ENABLE = true; SESSION_DURATION = 32532000; # SESSION_NAME deliberately omitted: the app only uses it behind an # isset() guard, so "unset" (fall back to PHP's own default) is safe. PHOTO_RESIZE_FACTOR_DEFAULT = 1.0; PHOTO_RESIZE_FACTOR_1 = 0.5; PHOTO_RESIZE_FACTOR_2 = 0.4; PHOTO_RESIZE_FACTOR_3 = 0.4; PHOTO_RESIZE_FACTOR_4 = 0.3; PHOTO_RESIZE_FACTOR_5 = 0.2; DB_ENABLE = true; DB_DRIVER = "mysql"; DB_HOST = "localhost"; DB_PORT = 3306; DB_DATABASE = cfg.database.name; DB_USER = cfg.user; DB_PASSWORD = ""; DB_CHARSET = "utf8mb4"; SMTP_AUTH = true; SMTP_FROMNAME = cfg.smtp.fromName; SMTP_FROMADDRESS = cfg.smtp.fromAddress; SMTP_HOST = cfg.smtp.host; SMTP_PORT = cfg.smtp.port; SMTP_USERNAME = cfg.smtp.username; SMTP_ENCRYPTION = cfg.smtp.encryption; LOG_ENABLE = true; } // cfg.settings; envFile = pkgs.writeText "hortusfox.env" ( lib.concatStringsSep "\n" (lib.mapAttrsToList mkEnvLine envVars) + "\n" ); # curl'd cron endpoints -> (name, path, calendar spec) cronjobs = { "tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; }; "tasks-tomorrow" = { path = "/cronjob/tasks/tomorrow"; when = "*-*-* 06,18:00:00"; }; "tasks-recurring" = { path = "/cronjob/tasks/recurring"; when = "hourly"; }; "calendar-reminder"= { path = "/cronjob/calendar/reminder";when = "*-*-* 07:00:00"; }; "backup-auto" = { path = "/cronjob/backup/auto"; when = "*-*-* 03:00:00"; }; }; in { options.services."hortusfox-web" = { enable = lib.mkEnableOption "HortusFox plant management system"; domain = lib.mkOption { type = lib.types.str; default = "localhost"; example = "plants.example.com"; description = "FQDN served over HTTPS (used for the nginx vhost + ACME)."; }; port = lib.mkOption { type = lib.types.nullOr lib.types.port; default = null; example = 8080; description = '' Port to append to the app's own canonical URL (APP_URL, used for login/session redirects and cron requests). Leave unset for a normal deployment reachable on the standard 80/443. Set this when the proxy is only reachable through a non-standard or forwarded port -- e.g. the QEMU test VM's hostfwd (see justfile) -- otherwise redirects drop the port and land somewhere unreachable. This does not change what port nginx/caddy actually listens on. ''; }; package = lib.mkOption { type = lib.types.package; default = pkgs.callPackage ../package.nix { }; description = "HortusFox package to deploy."; }; proxy.server = lib.mkOption { type = lib.types.enum [ "nginx" "caddy" ]; default = "nginx"; description = "Reverse proxy/webserver to front phpfpm."; }; proxy.ssl = lib.mkOption { type = lib.types.bool; default = true; description = '' Whether to force HTTPS and request an ACME certificate for the vhost. Disable for local/test deployments that have no real domain or internet access to reach Let's Encrypt. ''; }; phpPackage = lib.mkOption { type = lib.types.package; default = pkgs.php83; description = "Base PHP used to build the phpfpm environment."; }; user = lib.mkOption { type = lib.types.str; default = "hortusfox"; }; group = lib.mkOption { type = lib.types.str; default = "hortusfox"; }; timezone = lib.mkOption { type = lib.types.str; default = "UTC"; }; database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; }; secretFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; example = "/run/agenix/hortusfox.env"; description = '' File with secret KEY=VALUE lines (APP_KEY, SMTP_PASSWORD, API keys, ...) appended verbatim to the generated .env at runtime. Keep it out of the Nix store in production (e.g. agenix/sops-nix); a store path is fine for throwaway test configs. ''; }; adminAccount.passwordFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = '' File containing the initial admin account's password (plaintext, no trailing newline needed). HortusFox has no CLI/seed hook of its own for this -- its only "create user" flow lives in the admin panel, which itself requires already being logged in as an admin, and `migrate:fresh` creates no user rows at all. Without this set, a fresh deployment has no way to log in at all. Only used on first boot (guarded by the same stateDir/.installed marker as migrate:fresh); changing it afterwards has no effect on an existing account. Seeds a user named "admin", email "admin@''${domain}", bcrypt-hashed via `password_hash(..., PASSWORD_BCRYPT)` (matching UserModel's own hashing) and inserted directly into the UserModel table with the admin flag set, bypassing the app's normal two-step create-then-promote admin-panel flow. ''; }; settings = lib.mkOption { type = lib.types.attrsOf (lib.types.oneOf [ lib.types.str lib.types.int lib.types.bool ]); default = { }; example = { APP_DEBUG = true; APP_OVERDUETASK_HOURS = 6; }; description = '' Extra/overriding .env entries, merged over the generated defaults. Note that the `php asatru` CLI (used here for migrations) only runs when APP_DEBUG is true, so migrations silently no-op unless that's set here or in secretFile. ''; }; smtp = { host = lib.mkOption { type = lib.types.str; default = ""; }; port = lib.mkOption { type = lib.types.port; default = 587; }; username = lib.mkOption { type = lib.types.str; default = ""; }; fromName = lib.mkOption { type = lib.types.str; default = "HortusFox"; }; fromAddress = lib.mkOption { type = lib.types.str; default = "hortusfox@localhost"; }; encryption = lib.mkOption { type = lib.types.enum [ "tls" "ssl" "" ]; default = "tls"; }; }; }; config = lib.mkIf cfg.enable { users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; home = stateDir; }; users.groups.${cfg.group} = { }; # --- Database: unix-socket auth, no password needed ------------------ services.mysql = { enable = true; package = pkgs.mariadb_114; ensureDatabases = [ cfg.database.name ]; ensureUsers = [{ name = cfg.user; ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; }]; }; # --- Persistent writable dirs --------------------------------------- # World-readable (0755), same reasoning as RuntimeDirectoryMode above: # some of these (public/*) are nginx's webroot, and nginx (a different # user) needs to traverse them directly. Nothing secret lives at the # directory level -- .env (the one real secret under stateDir) is a # separate file installed at mode 600, unaffected by this. systemd.tmpfiles.rules = [ "d ${stateDir} 0755 ${cfg.user} ${cfg.group} - -" ] ++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirParents ++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirs; # Kept as one attrset: hortusfox-setup/phpfpm-hortusfox plus the # cron-generated services all live under systemd.services, and a # separate full reassignment (as the cron block used to be) would # conflict with these partial-path definitions. systemd.services = { hortusfox-setup = { description = "Prepare HortusFox runtime tree and run migrations"; after = [ "mysql.service" "network.target" ]; requires = [ "mysql.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = cfg.user; Group = cfg.group; StateDirectory = "hortusfox"; # World-readable: nginx (a different user) needs to stat/read # everything under here to serve `public/` directly. Nothing # secret lives in this tree -- .env is a symlink to stateDir/.env, # which stays mode 600 regardless of this. RuntimeDirectory = "hortusfox"; RuntimeDirectoryMode = "0755"; }; path = [ phpPackage config.services.mysql.package pkgs.coreutils pkgs.gnused ]; script = '' set -euo pipefail # 1. Mirror the read-only store package into a writable-structured # tree with a real (non-symlinked) copy of every file. This # duplicates the app into tmpfs on every boot -- NOT `cp -rs` # (per-file symlinks back into the store): PHP resolves # __DIR__/__FILE__ through symlinks to their real target, so # any symlinked file here would see itself as living in the # read-only store rather than under runDir, breaking the # app's own self-relative path lookups (its .env discovery # included). `cp` carries the store's read-only directory # mode (555) onto the new directories, so reopen them for # writing -- only directories, since we never need to touch # the copied files' own permissions. rm -rf "${runDir}" mkdir -p "${runDir}" cp -r "${appRoot}/." "${runDir}/" find "${runDir}" -type d -exec chmod u+w {} + # 2. Swap the writable subdirs for persistent state dirs. Some of # these (public/img, public/themes) ship real default assets # (backgrounds, the default theme) in the package, not just # user-generated content -- seed the persistent copy from the # package on every boot, without ever clobbering what's # already there, before replacing the dir with a symlink to # it. Harmless no-op for dirs that ship nothing but the app's # own .gitignore. ${lib.concatMapStringsSep "\n" (d: '' mkdir -p "${stateDir}/${d}" cp -rn "${appRoot}/${d}/." "${stateDir}/${d}/" || true find "${stateDir}/${d}" -type d -exec chmod u+w {} + rm -rf "${runDir}/${d}" ln -sfn "${stateDir}/${d}" "${runDir}/${d}" '') writableDirs} # 3. Render .env: static body + secrets from secretFile (if any), # appended verbatim. install -m600 /dev/null "${stateDir}/.env" cat ${envFile} > "${stateDir}/.env" ${lib.optionalString (cfg.secretFile != null) '' { echo "" cat "${cfg.secretFile}" } >> "${stateDir}/.env" ''} ln -sfn "${stateDir}/.env" "${runDir}/.env" # 4. Migrate. First boot builds the schema (destructive fresh); later # boots only upgrade. The marker guards you from nuking your data. cd "${runDir}" if [ ! -e "${stateDir}/.installed" ]; then php asatru migrate:fresh # 5. Seed the initial admin account. migrate:fresh creates no # user rows at all, and the app's only "create user" flow # lives behind the admin panel -- without this, a fresh # deployment has no way to log in, ever. Insert directly # (name/email hardcoded, bypassing the app's normal # create-then-promote-to-admin two-step flow) rather than # going through any app-level API, since none exists yet at # this point. ${lib.optionalString (cfg.adminAccount.passwordFile != null) '' admin_hash="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$(cat "${cfg.adminAccount.passwordFile}")")" mariadb "${cfg.database.name}" -e "INSERT INTO UserModel (name, email, password, admin) VALUES ('admin', 'admin@${cfg.domain}', '$admin_hash', 1);" ''} touch "${stateDir}/.installed" else php asatru migrate:upgrade || true fi ''; }; phpfpm-hortusfox = { after = [ "hortusfox-setup.service" ]; requires = [ "hortusfox-setup.service" ]; }; } // (lib.mapAttrs' (name: job: lib.nameValuePair "hortusfox-cron-${name}" { description = "HortusFox cronjob: ${name}"; after = [ "nginx.service" ]; serviceConfig = { Type = "oneshot"; User = cfg.user; ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 " + "${appUrl}${job.path}"; }; }) cronjobs); systemd.timers = lib.mapAttrs' (name: job: lib.nameValuePair "hortusfox-cron-${name}" { description = "Timer for HortusFox cronjob: ${name}"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = job.when; Persistent = true; }; }) cronjobs; # --- phpfpm pool ------------------------------------------------------ services.phpfpm.pools.hortusfox = { user = cfg.user; group = cfg.group; phpPackage = phpPackage; settings = { "listen.owner" = if cfg.proxy.server == "caddy" then config.services.caddy.user else config.services.nginx.user; "listen.group" = if cfg.proxy.server == "caddy" then config.services.caddy.group else config.services.nginx.group; "pm" = "dynamic"; "pm.max_children" = 16; "pm.start_servers" = 2; "pm.min_spare_servers" = 1; "pm.max_spare_servers" = 4; "catch_workers_output" = true; }; }; # --- nginx + ACME ------------------------------------------------------ services.nginx = lib.mkIf (cfg.proxy.server == "nginx") { enable = true; recommendedTlsSettings = true; recommendedGzipSettings = true; recommendedOptimisation = true; recommendedProxySettings = true; virtualHosts.${cfg.domain} = { forceSSL = cfg.proxy.ssl; enableACME = cfg.proxy.ssl; root = "${runDir}/public"; # Asatru front controller: everything that isn't a real file # rewrites to index.php (mirrors the upstream .htaccess). locations."/" = { index = "index.php"; tryFiles = "$uri $uri/ /index.php?$query_string"; }; locations."~ \\.php$".extraConfig = '' fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass unix:${config.services.phpfpm.pools.hortusfox.socket}; fastcgi_index index.php; include ${config.services.nginx.package}/conf/fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; ${lib.optionalString (cfg.port != null) '' # Asatru's own url()/asset() helpers (vendor/danielbrendel/asatru-php-framework/src/helper.php) # build every absolute URL straight from $_SERVER['SERVER_PORT'], never from APP_URL. That # default fastcgi_params' $server_port is the port nginx itself listens on -- e.g. the QEMU # test VM's unforwarded guest port 80 -- not the externally-reachable/forwarded one (8080), # so every asset/redirect URL silently drops the real port. Override it to match cfg.port. fastcgi_param SERVER_PORT "${toString cfg.port}"; ''} ''; # Don't let anyone fetch the rendered .env or app internals. locations."~ /\\.".extraConfig = "deny all;"; extraConfig = '' client_max_body_size 64M; ''; }; }; # --- caddy --------------------------------------------------------- # `proxy.ssl = false` prefixes the site address with "http://", which # disables Caddy's automatic HTTPS/ACME for this vhost specifically # (rather than needing a separate flag like nginx's forceSSL/enableACME). services.caddy = lib.mkIf (cfg.proxy.server == "caddy") { enable = true; virtualHosts.${if cfg.proxy.ssl then cfg.domain else "http://${cfg.domain}"}.extraConfig = '' root * ${runDir}/public encode gzip # Don't let anyone fetch the rendered .env or app internals. @dotfiles path /.* respond @dotfiles 403 request_body { max_size 64MB } # php_fastcgi already covers the Asatru front-controller rewrite # (try real file, then index.php) and static file serving. php_fastcgi unix/${config.services.phpfpm.pools.hortusfox.socket} { ${lib.optionalString (cfg.port != null) '' # See the matching nginx fastcgi_param SERVER_PORT override above: # Asatru's url()/asset() helpers build absolute URLs from # $_SERVER['SERVER_PORT'] alone, which Caddy otherwise sets to the # port it's actually listening on rather than an external/forwarded one. env SERVER_PORT ${toString cfg.port} ''} } file_server ''; }; # ACME needs an email; set this once globally in your host config: # nginx: security.acme = { acceptTerms = true; defaults.email = "you@example.com"; }; # caddy: services.caddy.email = "you@example.com"; networking.firewall.allowedTCPPorts = [ 80 443 ]; }; }