// 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 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" "regexp" "runtime" "strings" "time" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/i18n" "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{"bt", "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 /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 noStore(mux) } // noStore stops the browser caching any lectio-web response, so a rebuilt or // reinstalled server never has an old page, base.css or theme.css served from // cache (which presents as "switching themes stopped working" after an update). func noStore(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") h.ServeHTTP(w, r) }) } // 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") } // dateRe validates a ?date= query param before it is ever handed to // readings.Load/liturgy.Load, which build a filesystem cache path by string // concatenation from it -- an unvalidated date is a path-traversal vector. // Compiled once at package scope (not per request), the same shape as // internal/cli's dateRe. See resolveQuery. var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // 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 a single cfg.DefaultVersion (not the // full cfg.Versions set) when none were given, so a plain visit checks // exactly one version box. func requestVersions(cfg config.Config, r *http.Request) []string { if vs, ok := r.URL.Query()["v"]; ok && len(vs) > 0 { return vs } if len(cfg.WebVersions) > 0 { return append([]string(nil), cfg.WebVersions...) } return []string{cfg.DefaultVersion} } // 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 } // requestDisplay returns the ?display= override (normalized the same way // config.Load normalizes cfg.WebDisplay, so a bad/unknown value falls back // to "horizontal" rather than reaching RenderReadings unchecked), falling // back to cfg.WebDisplay when the param is absent. func requestDisplay(cfg config.Config, r *http.Request) string { if d := r.URL.Query().Get("display"); d != "" { return config.NormalizeDisplay(d) } return cfg.WebDisplay } // resolveQuery resolves the date/lectionary/all/versions/display controls // shared by indexHandler and readingsHandler from cfg (the defaults) and // r's query params (the overrides) -- one place for both handlers so they // can't drift. func resolveQuery(cfg config.Config, r *http.Request) (date, lectionary string, all bool, versions []string, display string) { date = r.URL.Query().Get("date") if date == "" || !dateRe.MatchString(date) { date = today() } lectionary = requestLectionary(cfg, r) all = queryBool(r, "all", cfg.All) versions = requestVersions(cfg, r) display = requestDisplay(cfg, r) return date, lectionary, all, versions, display } // loadSections runs the readings router for one request: date/lectionary // override cfg, all controls part filtering, and versions is swapped via // render.OfflineVersions -- when cfg.Offline (any lectionary needs the // network-free set), or when lectionary is "traditional" (pl is the // niedziela.pl modern scrape, meaningless for missalemeum) -- 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 versions = render.EffectiveVersions(versions, lectionary, cfg.Offline) 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 Display string Mono bool Reading template.HTML // L holds the localised control labels (lectionary/layout/theme/...), // set from i18n.Get(cfg.UILanguage) -- index.html references its // fields (e.g. {{.L.Lectionary}}) instead of hardcoded Polish text. L i18n.UI // Lang is cfg.UILanguage, rendered into . Lang string } 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, lectionary, all, versions, display := resolveQuery(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, display, cfg.UILanguage, err) // Check the boxes for the versions actually rendered (loadVersions), // not the raw request: traditional/offline substitute pl->wuj, so the // pl box must not show checked while Wujek is what's displayed. selected := map[string]bool{} for _, v := range loadVersions { 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, Display: display, Mono: queryBool(r, "mono", cfg.WebMono), Reading: reading, L: i18n.Get(cfg.UILanguage), Lang: cfg.UILanguage, } 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, lectionary, all, versions, display := resolveQuery(cfg, r) secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) reading := renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, 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, or no sections at all for the date) a small escaped error // paragraph, localised via lang -- 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, display, lang string, err error) template.HTML { if err != nil { return template.HTML(`
` + template.HTMLEscapeString(err.Error()) + `
`) } if len(secs) == 0 { return template.HTML(`` + template.HTMLEscapeString(i18n.Get(lang).NoReadingsDay) + `
`) } return RenderReadings(secs, versions, lectionary, display, lang) } // 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) } } // defaultWebPort is the port chooseListener prefers when cfg.WebPort is 0. const defaultWebPort = 1099 // chooseListener binds the port to serve on, on loopback only (127.0.0.1) -- // lectio-web is documented as a personal tool and Run prints an // http://localhost/... URL, so it must not be reachable from the LAN. port==0 // means "prefer defaultWebPort (1099), else let the OS pick a free port"; a // non-zero port is bound exactly (and its bind error surfaced if the port is // in use). func chooseListener(port int) (net.Listener, error) { if port != 0 { return net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) } if ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", defaultWebPort)); err == nil { return ln, nil } return net.Listen("tcp", "127.0.0.1:0") // 1099 taken -> any free port } // Run starts lectio-web: listens on cfg.WebPort via chooseListener (0 // prefers 1099, falling back to 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 := chooseListener(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() }