// 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())(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 // 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 } // No version selected -> fetch nothing and show nothing (empty pane, no // day-info/headings); loadVersions stays nil so no box is checked. var reading template.HTML var loadVersions []string if len(versions) > 0 { secs, dayInfo, lv, err := loadSections(cfg, lectionary, date, all, versions) loadVersions = lv reading = renderOrError(secs, lv, lectionary, display, cfg.UILanguage, dayInfo, 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) var reading template.HTML if len(versions) > 0 { secs, dayInfo, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) reading = renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, dayInfo, err) } w.Header().Set("Content-Type", "text/html; charset=utf-8") io.WriteString(w, string(reading)) } } // exportHandler serves GET /export?fmt=txt|md|pdf: the current day's readings // as a downloadable file (Content-Disposition attachment), reusing the same // date/lectionary/all/version resolution as the reading pane. func exportHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if l := r.URL.Query().Get("lang"); l != "" { cfg.UILanguage = config.NormalizeUILanguage(l) } date, lectionary, all, versions, _ := resolveQuery(cfg, r) secs, dayInfo, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if len(secs) == 0 { http.Error(w, i18n.Get(cfg.UILanguage).NoReadingsDay, http.StatusNotFound) return } version := cfg.DefaultVersion if len(loadVersions) > 0 { version = loadVersions[0] } switch r.URL.Query().Get("fmt") { case "pdf": data, perr := export.ReadingsPDF(date, dayInfo, secs, version, lectionary, cfg.UILanguage) if perr != nil { http.Error(w, perr.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.pdf"`) w.Write(data) case "md": w.Header().Set("Content-Type", "text/markdown; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.md"`) io.WriteString(w, export.Markdown(date, dayInfo, secs, version, lectionary, cfg.UILanguage)) default: w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.txt"`) io.WriteString(w, export.Text(date, dayInfo, secs, version, lectionary, cfg.UILanguage)) } } } // calendarHandler serves GET /calendar?month=YYYY-MM&lectionary=: a printable // A4 PDF month calendar of the celebration names + gospel references. Missing/ // bad month defaults to the current month. func calendarHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if l := r.URL.Query().Get("lang"); l != "" { cfg.UILanguage = config.NormalizeUILanguage(l) } t, err := time.Parse("2006-01", r.URL.Query().Get("month")) if err != nil { t = time.Now() } year, month := t.Year(), int(t.Month()) cfg.Lectionary = requestLectionary(cfg, r) first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) var days []export.CalendarDay for d := first; int(d.Month()) == month; d = d.AddDate(0, 0, 1) { cd := export.CalendarDay{Day: d.Day()} if secs, info, lerr := readings.Load(cfg, readings.Options{Date: d.Format("2006-01-02"), All: false}); lerr == nil { cd.Name = info.Name cd.Colour = info.Colour cd.Citation = readings.GospelCitation(secs) } days = append(days, cd) } data, perr := export.CalendarPDF(year, month, days, cfg.Lectionary, cfg.UILanguage) if perr != nil { http.Error(w, perr.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="lectio-%04d-%02d-calendar.pdf"`, year, month)) w.Write(data) } } // 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, dayInfo liturgy.DayInfo, 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, dayInfo) } // requestReaderVersions returns the corpus versions the reader request asks for // (repeated ?v=), dropping "bt" (no corpus) and anything invalid; falling back // to the configured default (or "wuj" when that is bt/invalid) so a plain visit // shows exactly one column. func requestReaderVersions(cfg config.Config, r *http.Request) []string { var out []string for _, v := range r.URL.Query()["v"] { if v != "bt" && config.ValidVersion(v) { out = append(out, v) } } if len(out) == 0 { d := cfg.DefaultVersion if d == "bt" || !config.ValidVersion(d) { d = "wuj" } out = []string{d} } return out } // findBook returns the BookInfo whose Canonical matches, and whether found. func findBook(books []bible.BookInfo, canonical string) (bible.BookInfo, bool) { for _, b := range books { if b.Canonical == canonical { return b, true } } return bible.BookInfo{}, false } // clampChap keeps chap within the book's available chapters (contiguous in // practice); 0 when the book has none in any corpus version. func clampChap(chap int, chaps []int) int { if len(chaps) == 0 { return 0 } if chap < chaps[0] { return chaps[0] } last := chaps[len(chaps)-1] if chap > last { return last } return chap } // atoiDefault parses s as an int, returning def when it is empty/unparsable. func atoiDefault(s string, def int) int { if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { return n } return def } type readerData struct { BookOpts []bookOpt Book string // canonical name of the selected book (for the bookmark-add form) Chap int PrevChap, NextChap int ChapOpts []chapOpt VersionOpts []versionOpt Display string ThemeOpts []themeOpt Theme string Mono bool Reading template.HTML L i18n.UI Lang string } type bookOpt struct { Value string // canonical name (corpus/query value) Label string // dialect display name Selected bool } type chapOpt struct { N int Selected bool } // readerHandler serves GET /reader: a book picker (dialect names), chapter // navigation and corpus-version compare, rendering the same reading-pane // templates/themes as the daily view. The controls form re-fetches /reader and // hx-selects #reader-root, so book/chapter/version selects stay in sync. func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { books := tbl.Books(cfg.SiglaLang()) if len(books) == 0 { // defensive: embedded table always has books http.Error(w, "web: no books", http.StatusInternalServerError) return } info, ok := findBook(books, r.URL.Query().Get("book")) if !ok { info = books[0] } versions := requestReaderVersions(cfg, r) chaps := UnionChapters(info.Canonical) first := 1 if len(chaps) > 0 { first = chaps[0] } chap := clampChap(atoiDefault(r.URL.Query().Get("chap"), first), chaps) display := requestDisplay(cfg, r) theme := r.URL.Query().Get("theme") if theme == "" { theme = cfg.WebTheme } reading := RenderPassage(info.Canonical, info.Name, chap, versions, display, cfg.UILanguage) bookOpts := make([]bookOpt, 0, len(books)) for _, b := range books { bookOpts = append(bookOpts, bookOpt{Value: b.Canonical, Label: b.Name, Selected: b.Canonical == info.Canonical}) } chapOpts := make([]chapOpt, 0, len(chaps)) for _, c := range chaps { chapOpts = append(chapOpts, chapOpt{N: c, Selected: c == chap}) } selected := map[string]bool{} for _, v := range versions { selected[v] = true } versionOpts := make([]versionOpt, 0, len(readerCorpusVersions)) for _, v := range readerCorpusVersions { 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}) } prev, next := chap-1, chap+1 if len(chaps) > 0 { prev = clampChap(chap-1, chaps) next = clampChap(chap+1, chaps) } data := readerData{ BookOpts: bookOpts, Book: info.Canonical, Chap: chap, PrevChap: prev, NextChap: next, ChapOpts: chapOpts, VersionOpts: versionOpts, Display: display, ThemeOpts: themeOpts, Theme: theme, 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, "reader.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } // addBookmark saves the current reader book+chapter with a note and tags, then // returns to the reader at that location. func addBookmark(s *server) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } book := r.PostForm.Get("book") chap := atoiOr(r.PostForm.Get("chap"), 0) verse := atoiOr(r.PostForm.Get("verse"), 0) if verse < 0 { verse = 0 } if book != "" && chap > 0 { _, _ = s.bm.Add(bookmarks.Bookmark{ Book: book, Chapter: chap, Verse: verse, Note: strings.TrimSpace(r.PostForm.Get("note")), Tags: bookmarks.ParseTags(r.PostForm.Get("tags")), }) } http.Redirect(w, r, "/reader?book="+url.QueryEscape(book)+"&chap="+strconv.Itoa(chap), http.StatusSeeOther) } } // bookmarkView is one row of templates/bookmarks.html. type bookmarkView struct { ID string Book string // canonical (for the ?book= open link) Display string // dialect display name Chapter int Verse int // 0 = whole chapter Note string Tags []string Created string } type bookmarksData struct { Items []bookmarkView AllTags []string Tag string // active filter ("" = all) L i18n.UI Lang string } // listBookmarks renders the bookmarks page, optionally filtered by ?tag=. func listBookmarks(s *server) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cfg := s.get() tag := r.URL.Query().Get("tag") items, err := s.bm.List(tag) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } names := map[string]string{} for _, b := range s.table().Books(cfg.SiglaLang()) { names[b.Canonical] = b.Name } views := make([]bookmarkView, 0, len(items)) for _, b := range items { disp := names[b.Book] if disp == "" { disp = b.Book } views = append(views, bookmarkView{ID: b.ID, Book: b.Book, Display: disp, Chapter: b.Chapter, Verse: b.Verse, Note: b.Note, Tags: b.Tags, Created: b.Created}) } allTags, _ := s.bm.Tags() data := bookmarksData{Items: views, AllTags: allTags, Tag: tag, L: i18n.Get(cfg.UILanguage), Lang: cfg.UILanguage} w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "bookmarks.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } // deleteBookmark removes a bookmark and returns to the list. func deleteBookmark(s *server) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } _ = s.bm.Delete(r.PostForm.Get("id")) http.Redirect(w, r, "/bookmarks", http.StatusSeeOther) } } // 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) } } // sourceHandler serves the AGPL-3.0 §13 source offer: where to obtain the // code this server is running. Every page footer links here, so a user // interacting with lectio-web over a network is never more than one click // from the corresponding source. // // Deliberately dependency-free (no template, no config read): it must answer // even when config is broken or a template failed to parse, because a §13 // offer that only works on healthy days is not an offer. Plain text keeps it // readable by both a browser and curl. func sourceHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") fmt.Fprintf(w, `lectio %s Licence: %s (GNU Affero General Public License, version 3 or later) Source: %s lectio is free software. You may use, study, share and modify it under the terms of the AGPL. The full licence text ships with the source as LICENSE. Because this is the Affero GPL, section 13 applies to this server: if the code running here has been MODIFIED, whoever operates it must offer you the modified source. If this address does not lead to the version you are talking to, that is the operator's obligation to fix, not lectio's. `, config.Version, config.License, config.SourceURL) } // settingsData drives templates/settings.html. type settingsData struct { Cfg config.Config Books string // current books.ini text (user override, else the embedded default) ThemeOpts []themeOpt // for the web_theme