diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/web/render.go | 15 | ||||
| -rw-r--r-- | internal/web/render_test.go | 24 | ||||
| -rw-r--r-- | internal/web/server.go | 322 | ||||
| -rw-r--r-- | internal/web/server_test.go | 107 | ||||
| -rw-r--r-- | internal/web/templates/index.html | 72 | ||||
| -rw-r--r-- | internal/web/templates/lookup.html | 20 |
6 files changed, 556 insertions, 4 deletions
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/<name>.css if it exists, // otherwise the embedded static/themes/<name>.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{{"<script>alert(1)</script>"}}, + }} + html := string(RenderReadings(secs, []string{"pl"}, "new")) + if strings.Contains(html, "<script>alert(1)</script>") { + t.Errorf("raw <script> leaked into rendered output: %q", html) + } + if !strings.Contains(html, "<script>alert(1)</script>") { + t.Errorf("expected escaped script tag in output: %q", html) + } +} + func TestUserTheme(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..b1a483f --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,322 @@ +// This file wires the package's render helpers (RenderReadings, Themes, +// themeCSS, the embedded static/templates FS) into an HTTP server: the full +// page (GET /), the HTMX reading-pane partial (GET /readings), a passage +// lookup partial (GET /lookup), a theme stylesheet endpoint (GET +// /theme.css) and the embedded static assets (GET /static/...). +package web + +import ( + "fmt" + "html/template" + "io" + "io/fs" + "net" + "net/http" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/lukaszkasprzak/lectio/internal/bible" + "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/liturgy" + "github.com/lukaszkasprzak/lectio/internal/readings" + "github.com/lukaszkasprzak/lectio/internal/render" +) + +// bibleVersions is the fixed, Themes-independent list of scripture versions +// the web UI's checkboxes offer -- independent of any one cfg.Versions, so +// every visitor sees the same five choices regardless of their config file. +var bibleVersions = []string{"pl", "wuj", "vul", "grb", "drb"} + +// NewServer builds lectio-web's route tree. cfg supplies the defaults +// (lectionary, versions, theme, offline) that requests can override via +// query parameters; it is never mutated. +func NewServer(cfg config.Config) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /{$}", indexHandler(cfg)) + mux.HandleFunc("GET /readings", readingsHandler(cfg)) + mux.HandleFunc("GET /lookup", lookupHandler(cfg)) + mux.HandleFunc("GET /theme.css", themeCSSHandler(cfg)) + + staticSub, err := fs.Sub(staticFS, "static") + if err != nil { + // Unreachable: "static" is embedded at build time by render.go. + panic(err) + } + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) + + return mux +} + +// today is lectio-web's "no ?date=" default, in the YYYY-MM-DD form every +// other package's Date fields expect. +func today() string { + return time.Now().Format("2006-01-02") +} + +// shiftDate adds days to date (YYYY-MM-DD); an unparsable date is returned +// unchanged, mirroring internal/tui's shiftDate. +func shiftDate(date string, days int) string { + t, err := time.Parse("2006-01-02", date) + if err != nil { + return date + } + return t.AddDate(0, 0, days).Format("2006-01-02") +} + +// requestVersions returns the versions requested via one or more repeated +// ?v= query params, falling back to cfg.Versions when none were given. +func requestVersions(cfg config.Config, r *http.Request) []string { + if vs, ok := r.URL.Query()["v"]; ok && len(vs) > 0 { + return vs + } + return append([]string(nil), cfg.Versions...) +} + +// queryBool reads a truthy/falsy query param ("1"/"true"/"on"/"yes" vs. +// "0"/"false"/"off"/"no"), falling back to def when the param is absent or +// unrecognized. +func queryBool(r *http.Request, name string, def bool) bool { + if !r.URL.Query().Has(name) { + return def + } + switch strings.ToLower(r.URL.Query().Get(name)) { + case "1", "true", "on", "yes": + return true + case "0", "false", "off", "no": + return false + default: + return def + } +} + +// requestLectionary returns the ?lectionary= override, falling back to +// cfg.Lectionary. +func requestLectionary(cfg config.Config, r *http.Request) string { + if l := r.URL.Query().Get("lectionary"); l != "" { + return l + } + return cfg.Lectionary +} + +// loadSections runs the readings router for one request: date/lectionary +// override cfg, all controls part filtering, and (when cfg.Offline) +// versions is swapped via render.OfflineVersions before being handed back +// to the caller for rendering -- so the caller's column labels always match +// what was actually loadable. +func loadSections(cfg config.Config, lectionary, date string, all bool, versions []string) ([]liturgy.Section, []string, error) { + cfg.Lectionary = lectionary + if cfg.Offline { + versions = render.OfflineVersions(versions) + } + secs, err := readings.Load(cfg, readings.Options{ + Date: date, + Offline: cfg.Offline, + All: all, + }) + return secs, versions, err +} + +// indexData is what templates/index.html ranges/branches over. +type indexData struct { + Date, PrevDate, NextDate string + Lectionary string + VersionOpts []versionOpt + All bool + ThemeOpts []themeOpt + Theme string + Ref string + Lookup template.HTML + Reading template.HTML +} + +type versionOpt struct { + Code string + Checked bool +} + +type themeOpt struct { + Name string + Selected bool +} + +// indexHandler serves the full page: controls reflecting the request's +// query (defaulting from cfg) plus the reading pane pre-rendered for that +// same query, so a plain (JS-less) GET / already shows today's gospel. +func indexHandler(cfg config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + date := r.URL.Query().Get("date") + if date == "" { + date = today() + } + lectionary := requestLectionary(cfg, r) + all := queryBool(r, "all", cfg.All) + versions := requestVersions(cfg, r) + theme := r.URL.Query().Get("theme") + if theme == "" { + theme = cfg.WebTheme + } + + secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) + reading := renderOrError(secs, loadVersions, lectionary, err) + + selected := map[string]bool{} + for _, v := range versions { + selected[v] = true + } + versionOpts := make([]versionOpt, 0, len(bibleVersions)) + for _, v := range bibleVersions { + versionOpts = append(versionOpts, versionOpt{Code: v, Checked: selected[v]}) + } + + themes := Themes() + themeOpts := make([]themeOpt, 0, len(themes)) + for _, name := range themes { + themeOpts = append(themeOpts, themeOpt{Name: name, Selected: name == theme}) + } + + data := indexData{ + Date: date, + PrevDate: shiftDate(date, -1), + NextDate: shiftDate(date, 1), + Lectionary: lectionary, + VersionOpts: versionOpts, + All: all, + ThemeOpts: themeOpts, + Theme: theme, + Ref: r.URL.Query().Get("ref"), // echoed into the lookup box on a shared/bookmarked link + Reading: reading, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +// readingsHandler serves the HTMX reading-pane partial: a fragment only, +// produced entirely by RenderReadings (never re-implemented here). +func readingsHandler(cfg config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + date := r.URL.Query().Get("date") + if date == "" { + date = today() + } + lectionary := requestLectionary(cfg, r) + all := queryBool(r, "all", cfg.All) + versions := requestVersions(cfg, r) + + secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) + reading := renderOrError(secs, loadVersions, lectionary, err) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(w, string(reading)) + } +} + +// renderOrError returns RenderReadings' fragment, or (on a readings.Load +// error) a small escaped error paragraph -- readings.Load errors are +// expected in normal operation (an unpublished date, no network while +// online, ...) so the pane should show them, not 500. +func renderOrError(secs []liturgy.Section, versions []string, lectionary string, err error) template.HTML { + if err != nil { + return template.HTML(`<p class="error">` + template.HTMLEscapeString(err.Error()) + `</p>`) + } + if len(secs) == 0 { + return template.HTML(`<p class="error">brak czytań na ten dzień</p>`) + } + return RenderReadings(secs, versions, lectionary) +} + +// lookupColumn is what templates/lookup.html ranges over: one per requested +// version. +type lookupColumn struct { + Version string + Verses []bible.Verse + Missing []string +} + +// lookupHandler serves the passage-lookup fragment: ref is looked up as-is +// (already English/kjv-style, e.g. "J 20:1") against each requested version +// via bible.Lookup directly -- no Polish->English conversion, that only +// applies to a section's own citation (render.GatherVersion), not a +// free-typed lookup. +func lookupHandler(cfg config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ref := strings.TrimSpace(r.URL.Query().Get("ref")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if ref == "" { + return + } + + versions := requestVersions(cfg, r) + cols := make([]lookupColumn, 0, len(versions)) + for _, v := range versions { + verses, missing := bible.Lookup(v, ref) + cols = append(cols, lookupColumn{Version: v, Verses: verses, Missing: missing}) + } + + if err := tmpl.ExecuteTemplate(w, "lookup.html", cols); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +// themeCSSHandler serves one theme's stylesheet: the requested name, or +// (unknown/invalid) cfg.WebTheme, or (that also unknown) the built-in +// default -- so an unrecognized ?name= degrades to a working theme instead +// of a broken page. +func themeCSSHandler(cfg config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + css, err := themeCSS(name) + if err != nil { + css, err = themeCSS(cfg.WebTheme) + } + if err != nil { + css, err = themeCSS(config.Default().WebTheme) + } + if err != nil { + http.Error(w, "web: no theme available", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Write(css) + } +} + +// Run starts lectio-web: listens on cfg.WebPort (0 picks a free OS port), +// prints the URL, best-effort opens it in a browser, and serves until the +// listener errors. +func Run(cfg config.Config) error { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WebPort)) + if err != nil { + return err + } + port := ln.Addr().(*net.TCPAddr).Port + url := fmt.Sprintf("http://localhost:%d", port) + fmt.Println("lectio-web on " + url) + + openBrowser(url) // best-effort; ignore failure (no browser, headless, ...) + + return http.Serve(ln, NewServer(cfg)) +} + +// openBrowser best-effort launches the platform's "open a URL" command; +// any failure (missing command, no display, ...) is silently ignored, as +// lectio-web is fully usable by just visiting the printed URL manually. +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", "", url) + default: + cmd = exec.Command("xdg-open", url) + } + _ = cmd.Start() +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..9e37177 --- /dev/null +++ b/internal/web/server_test.go @@ -0,0 +1,107 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/liturgy" +) + +// TestServer exercises NewServer's handler tree end to end via httptest, +// against the same fixture HTML/hook internal/readings uses (see +// readings_test.go TestLoadModernRoutes): no real network, no real browser. +func TestServer(t *testing.T) { + html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html") + if err != nil { + t.Fatal(err) + } + fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(html) + })) + defer fixtureServer.Close() + liturgy.SetBaseURL(fixtureServer.URL + "/liturgia/%s/Ewangelia") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + srv := NewServer(config.Default()) + + t.Run("index page", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Ewangelia") { + t.Errorf("body missing reading heading: %q", body) + } + if !strings.Contains(body, "htmx") { + t.Errorf("body missing htmx reference") + } + if !strings.Contains(body, `id="theme"`) { + t.Errorf("body missing theme <link>") + } + }) + + t.Run("readings partial", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=2026-07-22&v=wuj&all=1", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "<html") { + t.Errorf("partial is not a fragment: %q", body) + } + if !strings.Contains(body, "Ewangelia") { + t.Errorf("partial missing reading heading: %q", body) + } + }) + + t.Run("lookup", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/lookup?ref=J+20:1&v=wuj", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "20:1") { + t.Errorf("lookup result missing verse: %q", rec.Body.String()) + } + }) + + t.Run("theme.css", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/theme.css?name=benedictines", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Header().Get("Content-Type"), "css") { + t.Errorf("Content-Type = %q, want it to contain css", rec.Header().Get("Content-Type")) + } + }) + + t.Run("static", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/static/htmx.min.js", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if rec.Body.Len() == 0 { + t.Error("static/htmx.min.js served empty body") + } + }) + + t.Run("theme.css unknown falls back", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/theme.css?name=does-not-exist", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (fallback to cfg.WebTheme/default)", rec.Code) + } + if rec.Body.Len() == 0 { + t.Error("theme.css fallback served empty body") + } + }) +} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html new file mode 100644 index 0000000..4dd580f --- /dev/null +++ b/internal/web/templates/index.html @@ -0,0 +1,72 @@ +{{/* index.html renders the full lectio-web page: top-bar controls (date, + lectionary, version checkboxes, all/gospel toggle, theme select), a + passage-lookup form, and the pre-rendered reading pane (#pane). + + The controls form (#controls) carries hx-get="/readings" and fires on + any "change" bubbling up from its fields (htmx auto-includes an + hx-get element's enclosing form fields as the request's query + params -- no hx-include needed). The date +/- buttons are separate + hx-get elements nested in the same form so their own field values are + included too; they override "date" via hx-vals. The theme <select> + has no server round trip: it swaps the #theme <link>'s href directly. */}} +<!doctype html> +<html lang="pl"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>lectio</title> +<link rel="stylesheet" href="/static/base.css"> +<link id="theme" rel="stylesheet" href="/theme.css?name={{.Theme}}"> +<script src="/static/htmx.min.js"></script> +</head> +<body> +<div class="page"> + + <form id="controls" class="controls" hx-get="/readings" hx-target="#pane" hx-trigger="change"> + <span class="date-nav"> + <button type="button" hx-get="/readings" hx-target="#pane" + hx-vals='{"date":"{{.PrevDate}}"}'>←</button> + <input type="date" name="date" value="{{.Date}}"> + <button type="button" hx-get="/readings" hx-target="#pane" + hx-vals='{"date":"{{.NextDate}}"}'>→</button> + </span> + + <label>lekcjonarz + <select name="lectionary"> + <option value="new" {{if eq .Lectionary "new"}}selected{{end}}>nowy</option> + <option value="traditional" {{if eq .Lectionary "traditional"}}selected{{end}}>tradycyjny</option> + </select> + </label> + + {{range .VersionOpts}} + <label><input type="checkbox" name="v" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label> + {{end}} + + <label>zakres + <select name="all"> + <option value="0" {{if not .All}}selected{{end}}>Ewangelia</option> + <option value="1" {{if .All}}selected{{end}}>wszystkie części</option> + </select> + </label> + </form> + + <label class="theme-picker">motyw + <select id="theme-select" + onchange="document.getElementById('theme').href='/theme.css?name='+encodeURIComponent(this.value)"> + {{range .ThemeOpts}} + <option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Name}}</option> + {{end}} + </select> + </label> + + <form id="lookup-form" class="controls" hx-get="/lookup" hx-target="#lookup-results" hx-include="#controls"> + <input type="text" name="ref" placeholder="np. J 20:1" value="{{.Ref}}"> + <button type="submit">szukaj</button> + </form> + <div id="lookup-results">{{.Lookup}}</div> + + <div id="pane">{{.Reading}}</div> + +</div> +</body> +</html> 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 .}} +<div class="columns"> + {{range .}} + <div class="column"> + <h3 class="version-label">{{.Version}}</h3> + {{range .Verses}} + <p class="block verse"><span class="vnum">{{.Chapter}}:{{.Verse}}</span> {{.Text}}</p> + {{end}} + {{if .Missing}} + <p class="block error">brak: {{range $i, $m := .Missing}}{{if $i}}, {{end}}{{$m}}{{end}}</p> + {{end}} + </div> + {{end}} +</div> +{{end}} |
