aboutsummaryrefslogtreecommitdiff
path: root/internal/calfeed/ical.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 21:27:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 21:27:10 +0200
commit2901c73fde7ff19e8cdea00f933dc628d11c6ec6 (patch)
tree5ea265b5f27c0dc05f888cb84ae345e1751307e7 /internal/calfeed/ical.go
parent45e0b7652552867f7f99ffe22c968641510d5143 (diff)
downloadlectio-2901c73fde7ff19e8cdea00f933dc628d11c6ec6.tar.gz
lectio-2901c73fde7ff19e8cdea00f933dc628d11c6ec6.zip
feat(calfeed): RFC-5545 iCal renderer with injection-safe escaping
Diffstat (limited to 'internal/calfeed/ical.go')
-rw-r--r--internal/calfeed/ical.go92
1 files changed, 92 insertions, 0 deletions
diff --git a/internal/calfeed/ical.go b/internal/calfeed/ical.go
new file mode 100644
index 0000000..bce3710
--- /dev/null
+++ b/internal/calfeed/ical.go
@@ -0,0 +1,92 @@
+package calfeed
+
+import (
+ "strconv"
+ "strings"
+ "time"
+)
+
+// icalEscape neutralises RFC-5545 TEXT specials AND all CR/LF, so untrusted
+// celebration names / citations cannot inject iCal lines or properties.
+func icalEscape(s string) string {
+ s = strings.ReplaceAll(s, "\\", "\\\\")
+ s = strings.ReplaceAll(s, ";", "\\;")
+ s = strings.ReplaceAll(s, ",", "\\,")
+ s = strings.ReplaceAll(s, "\r\n", "\\n")
+ s = strings.ReplaceAll(s, "\r", "\\n")
+ s = strings.ReplaceAll(s, "\n", "\\n")
+ return s
+}
+
+// foldLine folds a content line at 75 octets with a leading space on
+// continuations (RFC 5545 §3.1). Counts bytes; folding runs after escaping.
+func foldLine(line string) string {
+ if len(line) <= 75 {
+ return line
+ }
+ var b strings.Builder
+ for i := 0; i < len(line); {
+ end := i + 75
+ if i > 0 {
+ end = i + 74 // account for the leading space
+ }
+ if end > len(line) {
+ end = len(line)
+ }
+ if i > 0 {
+ b.WriteString("\r\n ")
+ }
+ b.WriteString(line[i:end])
+ i = end
+ }
+ return b.String()
+}
+
+func calName(form string) string {
+ if form == "old" {
+ return "Lectio — Extraordinary Form"
+ }
+ return "Lectio — Ordinary Form"
+}
+
+// ICal renders days as an RFC-5545 VCALENDAR, one all-day VEVENT per day.
+func ICal(form string, days []DayView, stamp time.Time) []byte {
+ var lines []string
+ add := func(s string) { lines = append(lines, foldLine(s)) }
+ add("BEGIN:VCALENDAR")
+ add("VERSION:2.0")
+ add("PRODID:-//lectio//calendar//EN")
+ add("CALSCALE:GREGORIAN")
+ add("METHOD:PUBLISH")
+ add("X-WR-CALNAME:" + icalEscape(calName(form)))
+ ds := stamp.UTC().Format("20060102T150405Z")
+ for _, d := range days {
+ date := strings.ReplaceAll(d.Date, "-", "") // YYYYMMDD
+ next, _ := time.Parse("2006-01-02", d.Date)
+ end := next.AddDate(0, 0, 1).Format("20060102")
+ var desc []string
+ desc = append(desc, "Season: "+d.Season+" (week "+strconv.Itoa(d.Week)+")")
+ if d.Observed.Rank != "" {
+ desc = append(desc, "Rank: "+d.Observed.Rank)
+ }
+ desc = append(desc, "Colour: "+d.Colour)
+ for _, r := range d.Readings {
+ desc = append(desc, r.Part+": "+r.Citation)
+ }
+ add("BEGIN:VEVENT")
+ add("UID:" + d.Date + "-" + form + "@lectio") // input-free, stable
+ add("DTSTAMP:" + ds)
+ add("DTSTART;VALUE=DATE:" + date)
+ add("DTEND;VALUE=DATE:" + end)
+ add("SUMMARY:" + icalEscape(d.Observed.Name))
+ // join with real \n, then escape the whole string so \n -> \\n and every
+ // TEXT special is neutralised in one pass.
+ add("DESCRIPTION:" + icalEscape(strings.Join(desc, "\n")))
+ if d.Colour != "" {
+ add("CATEGORIES:" + icalEscape(strings.ToUpper(d.Colour)))
+ }
+ add("END:VEVENT")
+ }
+ add("END:VCALENDAR")
+ return []byte(strings.Join(lines, "\r\n") + "\r\n")
+}