Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <head> (first-paint optimization).
# Default points at the embedded Inter face. If you overlay css/fonts.css under
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion internal/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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)
}
16 changes: 16 additions & 0 deletions internal/services/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions internal/services/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<html><body>nothing to do with markgo</body></html>`),
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")
}