From 039e0838f3265ae929501c31c04c2d852f0750f7 Mon Sep 17 00:00:00 2001 From: Distronode <254259913+distronode-com@users.noreply.github.com> Date: Sun, 6 Sep 2026 07:40:27 -0400 Subject: [PATCH] config: DATA_DIR chooses the upload directory The upload directory (avatars, branding assets) was the literal relative "data" in server.BuildHandler, which every deployment has been writing to since the beginning and which a read-only container image running as an unprivileged user cannot create: the first avatar upload fails with EACCES and nothing at boot says why. DATA_DIR sets it, defaulting to the same relative "data", so nothing moves for anyone who never sets it. BuildHandler repeats the fallback because tests build a Config literal that skips Load, and an empty directory would put uploads beside the binary. --- internal/config/config.go | 8 ++++++++ internal/config/config_test.go | 13 +++++++++++++ internal/server/server.go | 9 ++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 60fb580..ac96fa0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,13 @@ type Config struct { ZoomClientID string ZoomClientSecret string + // DataDir is where uploaded files (avatars, branding assets) are written. + // Defaults to the relative directory "data", which is what every existing + // deployment has always used; set DATA_DIR when the process runs somewhere + // its working directory is not writable, such as a read-only container image + // that mounts a volume elsewhere. + DataDir string + // CookieSecure sets the Secure flag on session cookies. Defaults to true // when BASE_URL starts with https://, but can be overridden explicitly via // COOKIE_SECURE=false for HTTPS-terminated-at-proxy setups where the binary @@ -89,6 +96,7 @@ func Load() *Config { ZoomClientSecret: getEnv("ZOOM_CLIENT_SECRET", ""), EmbedAllowedOrigins: splitCSV(getEnv("EMBED_ALLOWED_ORIGINS", "")), + DataDir: getEnv("DATA_DIR", "data"), } cfg.EncryptionKey = os.Getenv("CALNODE_ENCRYPTION_KEY") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a3a9962..990f1e4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -121,3 +121,16 @@ func TestLoad_demoResetIntervalInvalidFallsBackToDefault(t *testing.T) { t.Errorf("DemoResetInterval = %v; want 30m default on invalid input", cfg.DemoResetInterval) } } + +// DATA_DIR moves the upload directory; unset, it is the relative "data" every +// existing deployment writes to, so nothing moves for anyone who never set it. +func TestLoad_dataDir(t *testing.T) { + t.Setenv("DATA_DIR", "") + if cfg := config.Load(); cfg.DataDir != "data" { + t.Errorf("DataDir default = %q; want data", cfg.DataDir) + } + t.Setenv("DATA_DIR", "/var/lib/calnode") + if cfg := config.Load(); cfg.DataDir != "/var/lib/calnode" { + t.Errorf("DataDir = %q; want /var/lib/calnode", cfg.DataDir) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d770024..d19e589 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -40,7 +40,14 @@ func BuildHandler(ctx context.Context, cfg *config.Config, db *sql.DB, logger *s h := handler.New(db, logger) h.SetBaseURL(cfg.BaseURL) h.SetPublicBaseURL(cfg.PublicBaseURL) - h.SetDataDir("data") + // DATA_DIR, defaulting to the relative "data" every deployment has always used. + // The fallback is repeated here because tests build a Config literal that skips + // Load, and an empty dir would put uploads beside the binary. + dataDir := cfg.DataDir + if dataDir == "" { + dataDir = "data" + } + h.SetDataDir(dataDir) h.SetEncKey(cfg.EncryptionKey) h.SetDemoMode(cfg.DemoMode) h.SetDemoResetInterval(cfg.DemoResetInterval)