// This file implements the two read-only calendar feed endpoints, // GET /api/calendar.json and GET /calendar.ics: security-critical per // docs/superpowers/specs/2026-07-27-lectio-calendar-api-design.md §Security // (D1-D7). All validation happens up front with fixed, input-free error // messages, and the day span is capped BEFORE any day is built. package web import ( "io" "net/http" "net/url" "strconv" "time" "github.com/lukaszkasprzak/lectio/internal/caldata" "github.com/lukaszkasprzak/lectio/internal/calendar" "github.com/lukaszkasprzak/lectio/internal/calfeed" "github.com/lukaszkasprzak/lectio/internal/config" ) // webMaxSpanDays caps the inclusive day span the WEB endpoints will build: // 1830 days (~5 years). Unlike the CLI's 100-year sanity cap (a trusted, // local surface), this bounds an untrusted network request's CPU and // response size (spec §Security D1). Enforced before any day is computed. const webMaxSpanDays = 1830 // minCalendarYear/maxCalendarYear bound ?year= to the domain the Gregorian // Computus (Easter algorithm) is valid for; years before the 1582 reform are // rejected. Mirrors cli.minCalendarYear/maxCalendarYear (unexported there, // so restated here rather than shared across packages for one pair of // constants). const ( minCalendarYear = 1583 maxCalendarYear = 9999 ) // feedErrMsg is the single fixed 400 body for every validation failure on // both endpoints -- it never echoes any part of the request (spec §Security // D3: reject up front, no reflected-content vector). const feedErrMsg = "lectio: invalid calendar query (want exactly one of date=, or from=&to=, or year=, plus optional form=old|new; dates YYYY-MM-DD, form old|new, year 1583-9999, from<=to, span<=1830 days)\n" // writeFeedError writes the fixed plain-text 400 response shared by both // feed endpoints. func writeFeedError(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusBadRequest) io.WriteString(w, feedErrMsg) } // parseFeedRange resolves exactly one of {date} | {from&to} | {year} from q // into an inclusive [from, to] range; any absence/ambiguity, parse failure, // out-of-domain year, inverted range, or over-cap span is reported as !ok // (spec §Security D1, D3). func parseFeedRange(q url.Values) (from, to time.Time, ok bool) { date := q.Get("date") fromStr := q.Get("from") toStr := q.Get("to") yearStr := q.Get("year") set := 0 if date != "" { set++ } if fromStr != "" || toStr != "" { set++ } if yearStr != "" { set++ } if set != 1 { return time.Time{}, time.Time{}, false } switch { case date != "": d, err := time.Parse("2006-01-02", date) if err != nil { return time.Time{}, time.Time{}, false } from, to = d, d case yearStr != "": year, err := strconv.Atoi(yearStr) if err != nil || year < minCalendarYear || year > maxCalendarYear { return time.Time{}, time.Time{}, false } from = time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) to = time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC) default: // from & to if fromStr == "" || toStr == "" { return time.Time{}, time.Time{}, false } var err error from, err = time.Parse("2006-01-02", fromStr) if err != nil { return time.Time{}, time.Time{}, false } to, err = time.Parse("2006-01-02", toStr) if err != nil { return time.Time{}, time.Time{}, false } if to.Before(from) { return time.Time{}, time.Time{}, false } } // D1: range cap enforced BEFORE any day is built, for every branch (a // single date/year is always well under the cap, but checking uniformly // keeps this the one place the guarantee lives). if days := int(to.Sub(from).Hours()/24) + 1; days > webMaxSpanDays { return time.Time{}, time.Time{}, false } return from, to, true } // parseFeedForm resolves the optional ?form= override: "" (absent) is valid // and means "use the server config's form"; anything present must be // old|new or the request is rejected (spec §Security D3 -- not silently // defaulted). func parseFeedForm(q url.Values) (form string, ok bool) { f := q.Get("form") if f == "" { return "", true } if f != "old" && f != "new" { return "", false } return f, true } // buildFeedDays validates r's query params and, on success, builds the day // list from the SERVER's own config + layers only (spec §Security D5: the // request supplies no calendar name, file path, or use= list -- only // date/from/to/year/form). form is the effective form actually used // (server default, or the validated override). func buildFeedDays(cfg config.Config, r *http.Request) (days []calfeed.DayView, form string, ok bool) { q := r.URL.Query() from, to, ok := parseFeedRange(q) if !ok { return nil, "", false } formOverride, ok := parseFeedForm(q) if !ok { return nil, "", false } sel := cfg.Selection() if formOverride != "" { sel.Form = formOverride } dir, _ := config.CalendarsDir() layers, _ := caldata.Stack(sel.Form, dir, cfg.Use) days = calfeed.Build(from, to, cfg.UILanguage, sel, layers, func(d time.Time, day calendar.LiturgicalDay) []calendar.Reading { return caldata.Readings(sel, layers, d, day) }) return days, sel.Form, true } // apiCalendarJSONHandler serves GET /api/calendar.json (spec §3): the // lectio.calendar/1 JSON envelope for the requested date/range. func apiCalendarJSONHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { days, form, ok := buildFeedDays(cfg, r) if !ok { writeFeedError(w) return } out, err := calfeed.JSON(form, days) if err != nil { http.Error(w, "lectio: internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.Write(out) } } // calendarICSHandler serves GET /calendar.ics (spec §4): an RFC-5545 // VCALENDAR for the requested date/range, suitable for webcal:// subscription. func calendarICSHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { days, form, ok := buildFeedDays(cfg, r) if !ok { writeFeedError(w) return } out := calfeed.ICal(form, days, time.Now()) w.Header().Set("Content-Type", "text/calendar; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.Write(out) } }