From 2901c73fde7ff19e8cdea00f933dc628d11c6ec6 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 21:27:10 +0200 Subject: feat(calfeed): RFC-5545 iCal renderer with injection-safe escaping --- internal/calfeed/ical.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 internal/calfeed/ical.go (limited to 'internal/calfeed/ical.go') 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") +} -- cgit v1.3