Compare commits

..
5 Commits
Author SHA1 Message Date
awkawbandClaude Sonnet 5 3b478f1403 Raise phpfpm upload_max_filesize/post_max_size to match the proxy configs
Both nginx (client_max_body_size) and caddy (request_body.max_size) are
already configured for 64M uploads, but PHP's own stock defaults
(upload_max_filesize=2M, post_max_size=8M) were never raised to match --
so PHP itself truncated/rejected any plant photo upload past 2M (any
modern phone camera JPEG) before PlantsModel::editPlantPhoto ever saw
it, surfacing as a bare "Errorneous file" 500 with no indication it was
ever a size limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 13:38:08 -04:00
awkawbandClaude Sonnet 5 407f335bac Fix cron jobs always 403ing: wire cronjob_pw through to the curl requests
CronjobsController requires a `cronpw` query param matching
AppModel.cronjob_pw, but cronjob_pw is generated at install time and
INSERTed straight into the DB -- nothing ever surfaced it to the cron
units' curl invocations, so every cron request has 403'd since this
service existed. Cache the value to a cfg.user-only-readable file at
generation time (backfilling it from the DB for already-installed
deployments), and have each cron unit read it back and append it as
`?cronpw=`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 18:48:41 -04:00
awkawbandClaude Sonnet 5 de7a4b7733 Track nixpkgs nixos-26.05 (stable) instead of nixos-25.05
Verified via nix flake check -L: package builds against PHP 8.4.26
with the existing vendorHash still valid, and both vm-test/vm-test-caddy
boot, migrate, and log in successfully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 11:10:23 -04:00
awkawb 88ed96550a Seed the AppModel settings row and expose language/workspace options
Upstream's own web installer inserts the single-row AppModel settings
table that the admin Environment tab (Language/Timezone/SMTP) reads
and writes via app()/AppModel::updateSet(). hortusfox-setup bypasses
that installer and never created this row, so AppModel::updateSet()'s
WHERE-less UPDATE matched zero rows: every admin "Save" reported
success but changed nothing, and app_mail_config() was nulling out
$_ENV['SMTP_*'] on every request from the same missing row, silently
breaking outbound mail. Seed the row on first boot, and add
language/workspace options (alongside the existing timezone) so
APP_LANG/APP_WORKSPACE and the seeded row both follow them.
2026-10-04 11:02:32 -04:00
awkawb 0c49938796 Track nixpkgs nixos-25.05 (stable) instead of nixos-unstable 2026-10-02 18:03:13 -04:00
4 changed files with 113 additions and 13 deletions
Generated
+4 -4
View File
@@ -2,16 +2,16 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1786599213, "lastModified": 1791031588,
"narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=", "narHash": "sha256-/FGDr01siZ8txrVzkOz6CYjiz0XR27JuVJP+aWchi4s=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4", "rev": "825e2028c29b702a4a5f085f08095d12099784f2",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-unstable", "ref": "nixos-26.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
description = "HortusFox — self-hosted plant manager, from source"; description = "HortusFox — self-hosted plant manager, from source";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
inputs.utils.url = "github:numtide/flake-utils"; inputs.utils.url = "github:numtide/flake-utils";
inputs.systems.url = "github:nix-systems/default"; inputs.systems.url = "github:nix-systems/default";
+101 -8
View File
@@ -23,6 +23,13 @@ let
runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs) runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs)
stateDir = "/var/lib/hortusfox"; # persistent writable data stateDir = "/var/lib/hortusfox"; # persistent writable data
# The app's CronjobsController requires a `?cronpw=` query param matching
# AppModel.cronjob_pw (see hortusfox-setup's step 5 below) -- cache that
# value here, readable only by cfg.user, so the cron units below can read
# it back at request time without ever going through Nix (cronjob_pw is
# DB-generated at install time, not a Nix-level secret).
cronPwFile = "${stateDir}/.cronjob-pw";
# Writable subdirs that must survive reboots. # Writable subdirs that must survive reboots.
writableDirs = [ writableDirs = [
"cache" "app/logs" "cache" "app/logs"
@@ -91,8 +98,8 @@ let
APP_CONTACT = "dbrendel1988@gmail.com"; APP_CONTACT = "dbrendel1988@gmail.com";
APP_DEBUG = false; APP_DEBUG = false;
APP_BASEDIR = ""; APP_BASEDIR = "";
APP_LANG = "en"; APP_LANG = cfg.language;
APP_WORKSPACE = "My workspace"; APP_WORKSPACE = cfg.workspace;
APP_OVERDUETASK_HOURS = 10; APP_OVERDUETASK_HOURS = 10;
APP_CRONJOB_MAILLIMIT = 5; APP_CRONJOB_MAILLIMIT = 5;
APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web"; APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web";
@@ -152,6 +159,20 @@ let
cronPort = if cfg.proxy.ssl then 443 else 80; cronPort = if cfg.proxy.ssl then 443 else 80;
cronUrl = path: "${scheme}://${cfg.domain}:${toString cronPort}${path}"; cronUrl = path: "${scheme}://${cfg.domain}:${toString cronPort}${path}";
# The actual ExecStart for each cron unit: read the cached cronjob_pw
# (written by hortusfox-setup) and append it as the `?cronpw=` query param
# CronjobsController requires, or every cron request gets a bare 403.
# A wrapper script rather than a plain curl invocation because systemd's
# ExecStart doesn't run through a shell, so it can't itself do the
# `$(cat ...)` substitution.
cronScript = name: job: pkgs.writeShellScript "hortusfox-cron-${name}" ''
set -euo pipefail
cronpw="$(${pkgs.coreutils}/bin/cat "${cronPwFile}")"
exec ${pkgs.curl}/bin/curl -fsS --max-time 300 \
--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 \
"${cronUrl job.path}?cronpw=$cronpw"
'';
# Baseline systemd sandboxing shared by this module's own units # Baseline systemd sandboxing shared by this module's own units
# (hortusfox-setup and the cron jobs; phpfpm-hortusfox gets its own, # (hortusfox-setup and the cron jobs; phpfpm-hortusfox gets its own,
# layered on top of what services.phpfpm already sets). Deliberately # layered on top of what services.phpfpm already sets). Deliberately
@@ -256,6 +277,23 @@ in {
timezone = lib.mkOption { type = lib.types.str; default = "UTC"; }; timezone = lib.mkOption { type = lib.types.str; default = "UTC"; };
language = lib.mkOption {
type = lib.types.str;
default = "en";
example = "de";
description = ''
UI language (APP_LANG). Must match one of the language codes
upstream ships translation files for under lang/.
'';
};
workspace = lib.mkOption {
type = lib.types.str;
default = "My workspace";
example = "Greenhouse";
description = "Workspace name shown in the UI (APP_WORKSPACE).";
};
database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; }; database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
secretFile = lib.mkOption { secretFile = lib.mkOption {
@@ -492,7 +530,43 @@ in {
if [ ! -e "${stateDir}/.installed" ]; then if [ ! -e "${stateDir}/.installed" ]; then
php asatru migrate:fresh php asatru migrate:fresh
# 5. Seed the initial admin account. migrate:fresh creates no # 5. Seed the AppModel settings row. migrate:fresh only creates
# the (empty) table -- upstream's own web installer is what
# inserts the single settings row that app()/AppModel's own
# admin-panel features (the Environment tab's Language/
# Timezone fields, SMTP toggle, etc.) read and write, and we
# bypass that installer entirely. Without this row,
# AppModel::updateSet()'s `UPDATE AppModel SET ...` -- no
# WHERE clause, since it's a de-facto single-row table --
# matches zero rows, so every "Save" in the admin Environment
# tab reports success but silently changes nothing. This
# includes Language/Timezone: those live here, not in .env
# (.env's APP_LANG is only a fallback used when this row is
# missing, and APP_TIMEZONE only sets PHP's global default
# timezone -- see AppHelper.php/bootstrap.php upstream,
# neither is what the admin UI's fields actually edit). It
# also means app_mail_config() -- called at the top of every
# single request/cronjob -- was overwriting $_ENV['SMTP_*']
# with NULLs read from this nonexistent row on every request,
# silently breaking outbound mail regardless of smtp.*/
# secretFile; seeding smtp_* here fixes that too. smtp_password
# is deliberately left NULL: it's secret-only (see
# secretFile), not a plain Nix option, and leaving it NULL is
# no regression versus the previous (rowless) behavior either
# way. cronjob_pw is generated the same way AppModel's own
# generateCronjobToken() does. Every other column either has
# no corresponding Nix option yet or just mirrors the
# migration's own column default, same as upstream's
# installer does.
cronjob_pw="$(php -r 'echo md5(random_bytes(55) . date("Y-m-d H:i:s"));')"
# Cache it for the cron units (see cronScript above) -- this is
# the only place that ever learns the value in plaintext outside
# the DB itself.
printf '%s' "$cronjob_pw" > "${cronPwFile}"
chmod 600 "${cronPwFile}"
mariadb "${cfg.database.name}" -e "INSERT INTO AppModel (id, workspace, language, timezone, scroller, chat_enable, chat_timelimit, chat_showusers, chat_indicator, chat_system, history_enable, history_name, enable_media_share, cronjob_pw, overlay_alpha, smtp_enable_auth, smtp_fromname, smtp_fromaddress, smtp_host, smtp_port, smtp_username, smtp_password, smtp_encryption, pwa_enable, owm_enable, owm_api_key, owm_latitude, owm_longitude, owm_unittype, owm_cache) VALUES (NULL, '${sqlEscape envVars.APP_WORKSPACE}', '${sqlEscape cfg.language}', '${sqlEscape cfg.timezone}', 1, 1, 5, 1, 1, 1, 1, 'History', 0, '$cronjob_pw', NULL, 1, '${sqlEscape cfg.smtp.fromName}', '${sqlEscape cfg.smtp.fromAddress}', '${sqlEscape cfg.smtp.host}', ${toString cfg.smtp.port}, '${sqlEscape cfg.smtp.username}', NULL, '${sqlEscape cfg.smtp.encryption}', 0, 0, NULL, NULL, NULL, 'default', 300);"
# 6. Seed the initial admin account. migrate:fresh creates no
# user rows at all, and the app's only "create user" flow # user rows at all, and the app's only "create user" flow
# lives behind the admin panel -- without this, a fresh # lives behind the admin panel -- without this, a fresh
# deployment has no way to log in, ever. Insert directly # deployment has no way to log in, ever. Insert directly
@@ -509,6 +583,15 @@ in {
else else
php asatru migrate:upgrade || true php asatru migrate:upgrade || true
fi fi
# Backfill for deployments that installed before cronjob_pw was
# cached to disk (it's always been DB-only until now): pull the
# value AppModel already has back out, rather than regenerating
# one that wouldn't match it.
if [ ! -s "${cronPwFile}" ]; then
mariadb -N "${cfg.database.name}" -e "SELECT cronjob_pw FROM AppModel LIMIT 1" > "${cronPwFile}"
chmod 600 "${cronPwFile}"
fi
''; '';
}; };
@@ -534,11 +617,10 @@ in {
serviceConfig = commonHardening // { serviceConfig = commonHardening // {
Type = "oneshot"; Type = "oneshot";
User = cfg.user; User = cfg.user;
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 " ExecStart = "${cronScript name job}";
+ "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 " # Only touches the filesystem to read cronPwFile back -- stateDir
+ "${cronUrl job.path}"; # stays visible (read-only) under ProtectSystem=strict since it
# A single outbound curl to the app's own loopback address -- # isn't under /home, so no ReadWritePaths/ReadOnlyPaths needed.
# doesn't touch the filesystem at all, so lock it down fully.
ProtectSystem = "strict"; ProtectSystem = "strict";
ProtectHome = "tmpfs"; ProtectHome = "tmpfs";
}; };
@@ -559,6 +641,17 @@ in {
user = cfg.user; user = cfg.user;
group = cfg.group; group = cfg.group;
phpPackage = phpPackage; phpPackage = phpPackage;
# PHP's own stock defaults (upload_max_filesize=2M, post_max_size=8M)
# are far below what both proxy configs below already allow through
# (nginx's client_max_body_size / caddy's request_body.max_size, both
# 64M) -- without this, PHP itself truncates/rejects any plant photo
# upload past 2M (a single modern phone camera JPEG, easily) before
# PlantsModel::editPlantPhoto ever sees it, surfacing as a bare
# "Errorneous file" 500 with no indication it was ever a size limit.
phpOptions = ''
upload_max_filesize = 64M
post_max_size = 64M
'';
settings = { settings = {
"listen.owner" = if cfg.proxy.server == "caddy" then config.services.caddy.user else config.services.nginx.user; "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; "listen.group" = if cfg.proxy.server == "caddy" then config.services.caddy.group else config.services.nginx.group;
+7
View File
@@ -92,5 +92,12 @@ pkgs.testers.nixosTest {
assert "/auth" not in final_url, ( assert "/auth" not in final_url, (
f"login didn't redirect away from /auth -- login failed (landed on {final_url}):\n{login_response}" f"login didn't redirect away from /auth -- login failed (landed on {final_url}):\n{login_response}"
) )
# The cron units curl the app's own cronjob endpoints with a `?cronpw=`
# query param matching AppModel.cronjob_pw -- exercise one directly
# rather than just trusting it, since this exact wiring broke silently
# in production (every cron request 403'd, forever, with nothing short
# of reading the journal ever surfacing it) before this assertion existed.
machine.succeed("systemctl start hortusfox-cron-tasks-recurring.service")
''; '';
} }