diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 14:39:31 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 14:39:31 +0200 |
| commit | 4de7f7dca7485c9a6c94f6f86a53d01a974fce54 (patch) | |
| tree | bcdc1ea366a068310e460804c4a5f04df3ae20f4 /internal/web/server.go | |
| parent | b72b81c410c161797eb525a6231b0517b4def993 (diff) | |
| download | lectio-4de7f7dca7485c9a6c94f6f86a53d01a974fce54.tar.gz lectio-4de7f7dca7485c9a6c94f6f86a53d01a974fce54.zip | |
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=<raw> reachable
from the network, so it owns closing this out.
Diffstat (limited to 'internal/web/server.go')
| -rw-r--r-- | internal/web/server.go | 322 |
1 files changed, 322 insertions, 0 deletions
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() +} |
