From 4de7f7dca7485c9a6c94f6f86a53d01a974fce54 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 23 Jul 2026 14:39:31 +0200 Subject: web: HTMX server + lectio-web binary NewServer wires B1's RenderReadings/Themes/themeCSS/embedded static+ templates FS into an http.ServeMux: GET / (full page), GET /readings (HTMX reading-pane fragment), GET /lookup (bible.Lookup passage search fragment), GET /theme.css (theme stylesheet, falling back through cfg.WebTheme to the built-in default on an unknown name), GET /static/. Run listens on cfg.WebPort (0 = OS-picked free port), prints the URL, best-effort opens a browser, then serves. cmd/lectio-web is the binary entry point (config.Load -> web.Run). Fold-in from the B1 review: hardened themeCSS's name guard to an explicit ^[A-Za-z0-9_-]+$ allowlist (the old filepath.Base/ContainsAny check let ".." through), plus guard-rejection and HTML-escaping regression tests -- B2 is what makes /theme.css?name= reachable from the network, so it owns closing this out. --- cmd/lectio-web/main.go | 21 +++ internal/web/render.go | 15 +- internal/web/render_test.go | 24 +++ internal/web/server.go | 322 +++++++++++++++++++++++++++++++++++++ internal/web/server_test.go | 107 ++++++++++++ internal/web/templates/index.html | 72 +++++++++ internal/web/templates/lookup.html | 20 +++ 7 files changed, 577 insertions(+), 4 deletions(-) create mode 100644 cmd/lectio-web/main.go create mode 100644 internal/web/server.go create mode 100644 internal/web/server_test.go create mode 100644 internal/web/templates/index.html create mode 100644 internal/web/templates/lookup.html diff --git a/cmd/lectio-web/main.go b/cmd/lectio-web/main.go new file mode 100644 index 0000000..44845b8 --- /dev/null +++ b/cmd/lectio-web/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" + + "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/web" +) + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := web.Run(cfg); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/web/render.go b/internal/web/render.go index cf72d41..67c7a37 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -130,14 +130,21 @@ func Themes() []string { return names } +// themeNameRe is the allowlist a theme name must match: letters, digits, +// underscore, hyphen only. Legitimate theme stems (built-in or user) already +// fit this shape; it is deliberately stricter than "no path separators" so +// it rejects "." / ".." / any other filesystem metacharacter outright +// instead of trying to enumerate what's unsafe. +var themeNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + // themeCSS returns one theme's CSS: the user file // ${XDG_CONFIG_HOME:-~/.config}/lectio/themes/.css if it exists, // otherwise the embedded static/themes/.css, otherwise an error. -// name must be a bare file stem (no path separators or "..") -- callers -// (B2's /theme.css?name= handler) pass this straight through from an HTTP -// query parameter, so this rejects path traversal rather than trusting it. +// name must match themeNameRe -- callers (B2's /theme.css?name= handler) +// pass this straight through from an HTTP query parameter, so this rejects +// path traversal rather than trusting it. func themeCSS(name string) ([]byte, error) { - if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) { + if !themeNameRe.MatchString(name) { return nil, fmt.Errorf("web: invalid theme name %q", name) } diff --git a/internal/web/render_test.go b/internal/web/render_test.go index c42e1b1..87ee62d 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -30,6 +30,30 @@ func TestBuiltinThemes(t *testing.T) { } } +func TestThemeCSSGuardRejectsInvalidNames(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no user themes + for _, name := range []string{"../../etc/passwd", "..", "a/b", ""} { + if _, err := themeCSS(name); err == nil { + t.Errorf("themeCSS(%q): expected error, got nil", name) + } + } +} + +func TestRenderReadingsEscapesScriptText(t *testing.T) { + secs := []liturgy.Section{{ + Heading: "Test", + PartID: "pierwsze_czytanie", + Paragraphs: [][]string{{""}}, + }} + html := string(RenderReadings(secs, []string{"pl"}, "new")) + if strings.Contains(html, "") { + t.Errorf("raw + + +
+ +
+ + + + + + + + + {{range .VersionOpts}} + + {{end}} + + +
+ + + +
+ + +
+
{{.Lookup}}
+ +
{{.Reading}}
+ +
+ + diff --git a/internal/web/templates/lookup.html b/internal/web/templates/lookup.html new file mode 100644 index 0000000..c8bc2a7 --- /dev/null +++ b/internal/web/templates/lookup.html @@ -0,0 +1,20 @@ +{{/* lookup.html renders the passage-lookup fragment: one .column per + requested version, each version's matched bible.Verse rows plus any + sub-refs the corpus had no entry for. Structurally mirrors + readings.html's .columns/.column/.block classes so theme CSS restyles + both panes the same way. */}} +{{if .}} +
+ {{range .}} +
+

{{.Version}}

+ {{range .Verses}} +

{{.Chapter}}:{{.Verse}} {{.Text}}

+ {{end}} + {{if .Missing}} +

brak: {{range $i, $m := .Missing}}{{if $i}}, {{end}}{{$m}}{{end}}

+ {{end}} +
+ {{end}} +
+{{end}} -- cgit v1.3