aboutsummaryrefslogtreecommitdiff
path: root/internal/web/apifeed.go
blob: 4d03fe14cd6fe45566fff73def99c07a21e9787f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
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)
	}
}