diff --git a/.env.example b/.env.example index ff98844..5078b40 100644 --- a/.env.example +++ b/.env.example @@ -25,8 +25,15 @@ BASE_URL=http://localhost:3000 # Path to articles directory (relative or absolute) ARTICLES_PATH=./articles -# Path to static assets directory -STATIC_PATH=./web/static +# Static asset overlay directory. LEAVE UNSET unless you are overriding specific +# files — the binary embeds every asset, and the embedded path is the fast one: +# pre-compressed brotli/gzip variants and a build-version ETag that revalidates +# with 304s. Setting this makes each request check your directory first and fall +# back to embedded per file, but every file you shadow loses compression and the +# ETag. Pointing it at a full copy of web/static therefore costs ~6x the bytes on +# every CSS/JS response and buys nothing. Override the handful of files you +# actually change: +# STATIC_PATH=/srv/markgo/static-overlay # Body font preloaded at high priority in (first-paint optimization). # Default points at the embedded Inter face. If you overlay css/fonts.css under @@ -36,8 +43,12 @@ STATIC_PATH=./web/static # warns at startup if this URL doesn't resolve in your static assets. FONT_PRELOAD_URL=/static/fonts/inter/inter-latin.woff2 -# Path to templates directory -TEMPLATES_PATH=./web/templates +# Templates directory. LEAVE UNSET unless you maintain a full template set — this +# is replace mode, not overlay: if the directory contains any *.html, it must +# provide EVERY template markgo needs, and the embedded set is not consulted for +# the gaps. Re-check your set against the embedded one after every upgrade, since +# a version that adds a template will fail to render against a stale directory: +# TEMPLATES_PATH=/srv/markgo/templates # ============================================================================= # BLOG INFORMATION diff --git a/CHANGELOG.md b/CHANGELOG.md index cfdc68d..51cf6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An unset `TEMPLATES_PATH` no longer globs the working directory. `filepath.Join("", + "*.html")` is `"*.html"`, so any unrelated `.html` file in the directory markgo happened + to start in was parsed as the template set, and boot failed with a set missing every + name it needed — a failure that appeared as a recipe breaking with no version or config + change, as the directory accumulated files. Empty now means embedded without touching + the filesystem. +- CLI errors print the wrapped cause. `Error: Failed to set up server` with the cause + dropped gave no indication of which subsystem failed; errors now go to stderr with a + `Cause:` line. This is the operator's own terminal, not an HTTP response body — the + v3.30.1 error-disclosure hardening applies to the latter. +- `.env.example` no longer ships `STATIC_PATH=./web/static` and + `TEMPLATES_PATH=./web/templates`. Both are documented as defaulting to empty, and the + example contradicted that: `cp .env.example .env` (per `docs/configuration.md`) put every + static asset behind the overlay, where shadowed files lose their pre-compressed variants + and build-version ETag — `main.css` served 38,725 bytes instead of 6,385, with no + validator to revalidate against. - Container health check works. The `HEALTHCHECK` directive carried a trailing `|| exit 1`, which makes Docker parse it as `CMD-SHELL` — unrunnable on a `scratch` image with no `/bin/sh` — and the `--health-check` flag it invoked was never diff --git a/docs/configuration.md b/docs/configuration.md index 6507bf5..d5aa4ea 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,8 +35,8 @@ Everything else has sensible defaults for development. | Variable | Default | Description | |----------|---------|-------------| | `ARTICLES_PATH` | `./articles` | Directory containing markdown files. | -| `STATIC_PATH` | *(empty)* | Overlay directory for static assets. When set and the directory exists, each request checks this path first and falls back to embedded assets per file. When unset or missing, all assets serve from the embedded FS. Set `LOG_LEVEL=debug` to log each overlay hit. Use atomic writes (write to a temp file, then `mv`) for in-place updates to avoid serving partial content. | -| `TEMPLATES_PATH` | *(empty)* | HTML templates directory. Optional — falls back to embedded templates if unset or missing. | +| `STATIC_PATH` | *(empty)* | Overlay directory for static assets. When set and the directory exists, each request checks this path first and falls back to embedded assets per file. When unset or missing, all assets serve from the embedded FS. **Overriding a file costs its compression and ETag** — embedded assets ship pre-compressed brotli/gzip variants with a build-version ETag that answers 304, and a shadowed file gets neither (`main.css` measures 6,385 bytes embedded vs 38,725 shadowed). Override only the files you actually change; never point this at a full copy of `web/static`. Set `LOG_LEVEL=debug` to log each overlay hit. Use atomic writes (write to a temp file, then `mv`) for in-place updates to avoid serving partial content. | +| `TEMPLATES_PATH` | *(empty)* | HTML templates directory. **Replace mode, not overlay** — if the directory contains any `*.html`, it must provide *every* template; embedded templates are not consulted for the gaps, so a version that adds a template will fail to render against a stale directory. Falls back to embedded when unset or when the directory holds no `*.html`. | ## Upload diff --git a/internal/errors/errors.go b/internal/errors/errors.go index fdde49d..01e1385 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -165,11 +165,19 @@ func HandleCLIError(err error, cleanup func()) { var exitCode = 1 var message string + // The wrapped cause is what says which subsystem failed. Dropping it leaves the + // operator with "Failed to set up server" and no next action. This is stderr on + // the operator's own machine, not an HTTP response body — the error-disclosure + // hardening that strips causes from JSON responses does not apply here, and + // gating this on ENVIRONMENT would withhold it from exactly the person whose + // production server will not boot. + var cause error var cliErr *CLIError if errors.As(err, &cliErr) { exitCode = cliErr.ExitCode message = cliErr.Message + cause = cliErr.Unwrap() } else { message = err.Error() } @@ -178,7 +186,10 @@ func HandleCLIError(err error, cleanup func()) { cleanup() } if message != "" { - fmt.Printf("Error: %s\n", message) + fmt.Fprintf(os.Stderr, "Error: %s\n", message) + } + if cause != nil && cause.Error() != message { + fmt.Fprintf(os.Stderr, "Cause: %v\n", cause) } os.Exit(exitCode) } diff --git a/internal/services/template.go b/internal/services/template.go index 83bce51..7601113 100644 --- a/internal/services/template.go +++ b/internal/services/template.go @@ -141,6 +141,18 @@ func NewTemplateService(templatesPath string, cfg *config.Config) (*TemplateServ func (t *TemplateService) loadTemplates(templatesPath string) error { funcMap := t.funcMap() + + // An unset TEMPLATES_PATH means embedded, and must not reach the glob: + // filepath.Join("", "*.html") is "*.html", which globs the process working + // directory. Any unrelated .html file there would then be parsed as the + // template set, and boot fails with a set missing every name it needs — a + // failure that depends on files having nothing to do with markgo, in a + // directory the operator may not have chosen. + if templatesPath == "" { + slog.Info("TEMPLATES_PATH unset, using embedded templates") + return t.loadEmbeddedTemplates(funcMap) + } + pattern := filepath.Join(templatesPath, "*.html") // Check if filesystem templates exist before parsing @@ -161,6 +173,10 @@ func (t *TemplateService) loadTemplates(templatesPath string) error { // No filesystem templates — fall back to embedded slog.Info("Filesystem templates not found, using embedded templates", "path", templatesPath) + return t.loadEmbeddedTemplates(funcMap) +} + +func (t *TemplateService) loadEmbeddedTemplates(funcMap template.FuncMap) error { embeddedFS, subErr := fs.Sub(web.Assets, "templates") if subErr != nil { return apperrors.NewHTTPError(500, fmt.Sprintf("Failed to access embedded templates: %v", subErr), apperrors.ErrTemplateParseError) diff --git a/internal/services/template_test.go b/internal/services/template_test.go index 98acf1c..41af46e 100644 --- a/internal/services/template_test.go +++ b/internal/services/template_test.go @@ -1178,3 +1178,32 @@ func TestBaseHead_FontPreload(t *testing.T) { out = render("") assert.NotContains(t, out, `rel="preload"`, "empty FONT_PRELOAD_URL must emit no preload") } + +// An unset TEMPLATES_PATH must resolve to embedded templates regardless of what +// happens to be in the process working directory. filepath.Join("", "*.html") is +// "*.html", so before the fix an unrelated .html file in CWD was parsed as the +// template set and boot failed with a set missing every name it needs. +func TestLoadTemplates_EmptyPathIgnoresStrayHTMLInWorkingDir(t *testing.T) { + strayDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(strayDir, "unrelated.html"), + []byte(`nothing to do with markgo`), + 0o600, + )) + + // Run with the stray file in CWD, restoring afterwards. + origWD, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(strayDir)) + t.Cleanup(func() { _ = os.Chdir(origWD) }) + + service, err := NewTemplateService("", &config.Config{}) + require.NoError(t, err, "empty TEMPLATES_PATH must fall back to embedded, not glob CWD") + require.NotNil(t, service.templates) + + // The embedded set is what got loaded, not the stray file. + assert.NotNil(t, service.templates.Lookup("base.html"), + "embedded base.html must be present") + assert.Nil(t, service.templates.Lookup("unrelated.html"), + "a stray CWD file must never enter the template set") +}