aboutsummaryrefslogtreecommitdiff
path: root/internal/web
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web')
-rw-r--r--internal/web/apifeed.go194
-rw-r--r--internal/web/apifeed_test.go110
-rw-r--r--internal/web/server.go2
3 files changed, 306 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)
+ }
+}
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))