From b423759bf017d8fa1e45e5d1ce978f1b8d0a6a9f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 21:41:38 +0200 Subject: feat(web): /api/calendar.json + /calendar.ics endpoints (capped, nosniff, validated) --- internal/web/apifeed.go | 194 +++++++++++++++++++++++++++++++++++++++++++ internal/web/apifeed_test.go | 110 ++++++++++++++++++++++++ internal/web/server.go | 2 + 3 files changed, 306 insertions(+) create mode 100644 internal/web/apifeed.go create mode 100644 internal/web/apifeed_test.go (limited to 'internal/web') 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) + } +} diff --git a/internal/web/apifeed_test.go b/internal/web/apifeed_test.go new file mode 100644 index 0000000..97b657e --- /dev/null +++ b/internal/web/apifeed_test.go @@ -0,0 +1,110 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lukaszkasprzak/lectio/internal/config" +) + +// TestAPICalendarJSON exercises GET /api/calendar.json's success path: a +// single ?date= resolves to exactly one day, with the correct Content-Type, +// nosniff, and a body that parses as the lectio.calendar/1 envelope. +func TestAPICalendarJSON(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/api/calendar.json?date=2026-01-06", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q", ct) + } + if ns := rec.Header().Get("X-Content-Type-Options"); ns != "nosniff" { + t.Errorf("X-Content-Type-Options = %q, want nosniff", ns) + } + var out struct { + Schema string `json:"schema"` + Form string `json:"form"` + Days []struct { + Date string `json:"date"` + } `json:"days"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("body did not parse as JSON: %v; body=%s", err, rec.Body.String()) + } + if len(out.Days) != 1 { + t.Fatalf("got %d days, want 1", len(out.Days)) + } +} + +// TestCalendarICS exercises GET /calendar.ics's success path: a whole ?year= +// resolves to a VCALENDAR body, with the correct Content-Type and nosniff. +func TestCalendarICS(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/calendar.ics?year=2026", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/calendar; charset=utf-8" { + t.Errorf("Content-Type = %q", ct) + } + if ns := rec.Header().Get("X-Content-Type-Options"); ns != "nosniff" { + t.Errorf("X-Content-Type-Options = %q, want nosniff", ns) + } + if !strings.Contains(rec.Body.String(), "BEGIN:VCALENDAR") { + t.Errorf("body missing BEGIN:VCALENDAR") + } +} + +// TestAPICalendarJSONBadDate: an unparsable date is rejected with 400 (D3). +func TestAPICalendarJSONBadDate(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/api/calendar.json?date=not-a-date", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +// TestAPICalendarJSONBadForm: an out-of-enum form is rejected with 400, not +// silently defaulted (D3). +func TestAPICalendarJSONBadForm(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/api/calendar.json?from=2026-01-01&to=2026-01-02&form=bogus", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +// TestCalendarICSOverCap: a range exceeding the 1830-day web cap (D1) is +// rejected with 400 BEFORE any day is built -- the body must not contain a +// VEVENT, proving the request never reached compute. +func TestCalendarICSOverCap(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/calendar.ics?from=2000-01-01&to=2100-01-01", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if strings.Contains(rec.Body.String(), "BEGIN:VEVENT") { + t.Errorf("over-cap request produced a VEVENT: %s", rec.Body.String()) + } +} + +// TestAPICalendarJSONInverted: from after to is rejected with 400. +func TestAPICalendarJSONInverted(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/api/calendar.json?from=2026-02-01&to=2026-01-01", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 2674802..fce0f22 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -70,6 +70,8 @@ func NewServer(cfg config.Config) http.Handler { 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 /settings", settingsGet(s)) -- cgit v1.3