// 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" "net/url" "os" "os/exec" "regexp" "runtime" "strconv" "strings" "sync" "time" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/bookmarks" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/export" "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{"wuj", "vul", "grb", "drb"} // server holds the live, mutable config + book table so /settings can apply // changes to the running process. All handlers read a snapshot via get()/table(). type server struct { mu sync.RWMutex cfg config.Config tbl *bible.BookTable bm *bookmarks.Store } func (s *server) get() config.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.cfg } func (s *server) table() *bible.BookTable { s.mu.RLock(); defer s.mu.RUnlock(); return s.tbl } func (s *server) apply(cfg config.Config, tbl *bible.BookTable) { s.mu.Lock() defer s.mu.Unlock() s.cfg = cfg if tbl != nil { s.tbl = tbl } } // 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 -- /settings applies changes to a // live, mutable copy held by server, which every handler reads per request. func NewServer(cfg config.Config) http.Handler { tbl, _ := bible.LoadBookTable(config.UserBooksINI()) s := &server{cfg: cfg, tbl: tbl, bm: bookmarks.Open()} mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { indexHandler(s.get())(w, r) }) mux.HandleFunc("GET /readings", func(w http.ResponseWriter, r *http.Request) { readingsHandler(s.get())(w, r) }) mux.HandleFunc("GET /export", func(w http.ResponseWriter, r *http.Request) { exportHandler(s.get())(w, r) }) mux.HandleFunc("GET /calendar", func(w http.ResponseWriter, r *http.Request) { calendarHandler(s.get())(w, r) }) mux.HandleFunc("GET /api/calendar.json", func(w http.ResponseWriter, r *http.Request) { apiCalendarJSONHandler(s.get())(w, r) }) mux.HandleFunc("GET /calendar.ics", func(w http.ResponseWriter, r *http.Request) { calendarICSHandler(s.get())(w, r) }) mux.HandleFunc("GET /reader", func(w http.ResponseWriter, r *http.Request) { readerHandler(s.get(), s.table(), s.bm)(w, r) }) mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) { themeCSSHandler(s.get())(w, r) }) mux.HandleFunc("GET /source", sourceHandler) mux.HandleFunc("GET /settings", settingsGet(s)) mux.HandleFunc("POST /settings", settingsPost(s)) mux.HandleFunc("POST /reader/bookmark", addBookmark(s)) mux.HandleFunc("GET /bookmarks", listBookmarks(s)) mux.HandleFunc("POST /bookmarks/delete", deleteBookmark(s)) 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. The controls form always submits a hidden "vset" marker, // so a request that carries "vset" but no "v" means the user unchecked EVERY // version -- return none (the pane then shows nothing). A request with neither // (a fresh visit or a bare link) falls back to cfg.WebVersions, else a single // cfg.DefaultVersion, so a plain visit checks exactly one box. func requestVersions(cfg config.Config, r *http.Request) []string { if vs, ok := r.URL.Query()["v"]; ok && len(vs) > 0 { return vs } if r.URL.Query().Has("vset") { return nil // form submitted with every box unchecked } 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 computes one request's readings offline: date/lectionary // override cfg, all controls part filtering, and versions is passed through // render.EffectiveVersions (which maps any legacy "bt" to "wuj") so the // caller's column labels always match what was actually loadable. dayInfo is // the day's celebration identity (see liturgy.DayInfo), zero when none. func loadSections(cfg config.Config, lectionary, date string, all bool, versions []string) (secs []liturgy.Section, dayInfo liturgy.DayInfo, effVersions []string, err error) { cfg.Lectionary = lectionary effVersions = render.EffectiveVersions(versions, lectionary, cfg.Offline) secs, dayInfo, err = readings.Load(cfg, readings.Options{Date: date, All: all}) return secs, dayInfo, effVersions, 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 // MonthOpts/YearOpts populate the "pobierz" panel's month-calendar // download pickers (localised month names + a year window around the // shown date). MonthOpts, YearOpts []calOpt // 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 } // calOpt is one