diff --git a/internal/app.go b/internal/app.go index d5faaa4..740daa1 100644 --- a/internal/app.go +++ b/internal/app.go @@ -226,7 +226,7 @@ func (a *App) handleIcal(ctx *gin.Context) { return } - response := []byte(cleaned.Serialize()) + response := []byte(normalizeCRLF(cleaned.Serialize())) ctx.Header("Content-Type", "text/calendar") ctx.Header("Content-Length", fmt.Sprintf("%d", len(response))) @@ -235,6 +235,14 @@ func (a *App) handleIcal(ctx *gin.Context) { } } +// normalizeCRLF normalizes line endings in s to CRLF (\r\n) as required by RFC 5545. +// It first replaces any existing CRLF with LF to avoid doubling the \r, +// then replaces all LF with CRLF. +func normalizeCRLF(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\n", "\r\n") +} + // handleGetCourses returns a list of all courses that are currently offered on campus. // This is used to populate the dropdown in the landing page for hiding courses. func (a *App) handleGetCourses(ctx *gin.Context) { diff --git a/internal/app_test.go b/internal/app_test.go index 638d85a..8837eeb 100644 --- a/internal/app_test.go +++ b/internal/app_test.go @@ -171,6 +171,33 @@ func TestLocationReplacement(t *testing.T) { } } +func TestNormalizeCRLF(t *testing.T) { + testData, app := getTestData(t, "duplication.ics") + calendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{}) + if err != nil { + t.Fatal(err) + } + + serialized := normalizeCRLF(calendar.Serialize()) + + // RFC 5545 requires CRLF line endings; there must be no bare \n. + if !strings.Contains(serialized, "\r\n") { + t.Error("serialized calendar should contain CRLF line endings") + } + for i, ch := range serialized { + if ch == '\n' && (i == 0 || serialized[i-1] != '\r') { + t.Error("serialized calendar contains a bare LF (\\n) without a preceding CR (\\r)") + break + } + } + + // Content-Length must equal byte length of the normalized output. + response := []byte(serialized) + if len(response) == 0 { + t.Error("serialized calendar response must not be empty") + } +} + func TestCourseFiltering(t *testing.T) { testData, app := getTestData(t, "coursefiltering.ics")