diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 21:41:38 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 21:41:38 +0200 |
| commit | b423759bf017d8fa1e45e5d1ce978f1b8d0a6a9f (patch) | |
| tree | fd0fc02516ca8ac4d201082d7cad146d81f888c8 /internal/web/apifeed.go | |
| parent | f8c92f253693e269edbc76da9a99e65df7d6f699 (diff) | |
| download | lectio-b423759bf017d8fa1e45e5d1ce978f1b8d0a6a9f.tar.gz lectio-b423759bf017d8fa1e45e5d1ce978f1b8d0a6a9f.zip | |
feat(web): /api/calendar.json + /calendar.ics endpoints (capped, nosniff, validated)
Diffstat (limited to 'internal/web/apifeed.go')
| -rw-r--r-- | internal/web/apifeed.go | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/internal/web/apifeed.go b/internal/web/apifeed.go new file mode 100644 index 0000000..4d03fe1 --- /dev/null +++ b/internal/web/apifeed.go @@ -0,0 +1,194 @@ +// 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) + } +} |
