diff options
Diffstat (limited to 'internal/calfeed')
| -rw-r--r-- | internal/calfeed/ical.go | 8 | ||||
| -rw-r--r-- | internal/calfeed/ical_test.go | 18 | ||||
| -rw-r--r-- | internal/calfeed/json.go | 21 | ||||
| -rw-r--r-- | internal/calfeed/json_test.go | 27 |
4 files changed, 69 insertions, 5 deletions
diff --git a/internal/calfeed/ical.go b/internal/calfeed/ical.go index bce3710..810847d 100644 --- a/internal/calfeed/ical.go +++ b/internal/calfeed/ical.go @@ -4,6 +4,8 @@ import ( "strconv" "strings" "time" + + "github.com/lukaszkasprzak/lectio/internal/config" ) // icalEscape neutralises RFC-5545 TEXT specials AND all CR/LF, so untrusted @@ -58,6 +60,12 @@ func ICal(form string, days []DayView, stamp time.Time) []byte { add("PRODID:-//lectio//calendar//EN") add("CALSCALE:GREGORIAN") add("METHOD:PUBLISH") + // AGPL-3.0 §13 offer for subscribers who only ever see the .ics feed. + // X- properties are the RFC-5545 extension point; unknown ones are + // ignored by clients, so this is inert for consumers and present for + // anyone who looks. + add("X-LECTIO-SOURCE:" + icalEscape(config.SourceURL)) + add("X-LECTIO-LICENSE:" + icalEscape(config.License)) add("X-WR-CALNAME:" + icalEscape(calName(form))) ds := stamp.UTC().Format("20060102T150405Z") for _, d := range days { diff --git a/internal/calfeed/ical_test.go b/internal/calfeed/ical_test.go index fb7ee9a..215c0e3 100644 --- a/internal/calfeed/ical_test.go +++ b/internal/calfeed/ical_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "time" + + "github.com/lukaszkasprzak/lectio/internal/config" ) func TestICalEscapeInjection(t *testing.T) { @@ -19,6 +21,22 @@ func TestICalEscapeInjection(t *testing.T) { } } +// TestICalCarriesSourceOffer pins the AGPL-3.0 §13 offer into the VCALENDAR +// header. Someone who subscribes to /calendar.ics in their calendar app sees +// no lectio page at all; the feed is their entire view of the program, so it +// carries the offer. X- properties are inert to clients that ignore them. +func TestICalCarriesSourceOffer(t *testing.T) { + out := string(ICal("new", nil, time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC))) + for _, want := range []string{ + "X-LECTIO-SOURCE:" + icalEscape(config.SourceURL), + "X-LECTIO-LICENSE:" + icalEscape(config.License), + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } +} + func TestICalStructure(t *testing.T) { days := []DayView{{ Date: "2026-01-06", Season: "time-after-epiphany", Week: 1, Colour: "white", diff --git a/internal/calfeed/json.go b/internal/calfeed/json.go index c1a50ca..1cb0b6f 100644 --- a/internal/calfeed/json.go +++ b/internal/calfeed/json.go @@ -1,17 +1,28 @@ package calfeed -import "encoding/json" +import ( + "encoding/json" + + "github.com/lukaszkasprzak/lectio/internal/config" +) const Schema = "lectio.calendar/1" // JSON renders days as the stable lectio.calendar/1 envelope. +// +// "source" and "license" carry the AGPL-3.0 §13 offer to consumers who only +// ever see this endpoint and never load the HTML UI. They are envelope +// metadata, not day data -- adding them does not change the schema version, +// since existing consumers read "days". func JSON(form string, days []DayView) ([]byte, error) { if days == nil { days = []DayView{} } return json.MarshalIndent(struct { - Schema string `json:"schema"` - Form string `json:"form"` - Days []DayView `json:"days"` - }{Schema, form, days}, "", " ") + Schema string `json:"schema"` + Form string `json:"form"` + Source string `json:"source"` + License string `json:"license"` + Days []DayView `json:"days"` + }{Schema, form, config.SourceURL, config.License, days}, "", " ") } diff --git a/internal/calfeed/json_test.go b/internal/calfeed/json_test.go index d2bc856..aec3d14 100644 --- a/internal/calfeed/json_test.go +++ b/internal/calfeed/json_test.go @@ -3,6 +3,8 @@ package calfeed import ( "encoding/json" "testing" + + "github.com/lukaszkasprzak/lectio/internal/config" ) func TestJSONShape(t *testing.T) { @@ -33,3 +35,28 @@ func TestJSONShape(t *testing.T) { t.Fatalf("bad day: %s", b) } } + +// TestJSONCarriesSourceOffer pins the AGPL-3.0 §13 offer into the envelope. +// A consumer of /api/calendar.json may never load a single HTML page, so the +// footer link does not reach them -- the feed itself has to say where the +// source is. Dropping these fields is a licence-compliance regression, not a +// cosmetic one, which is why it is asserted rather than left to review. +func TestJSONCarriesSourceOffer(t *testing.T) { + b, err := JSON("new", nil) + if err != nil { + t.Fatal(err) + } + var out struct { + Source string `json:"source"` + License string `json:"license"` + } + if err := json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + if out.Source != config.SourceURL { + t.Errorf("source = %q, want %q", out.Source, config.SourceURL) + } + if out.License != config.License { + t.Errorf("license = %q, want %q", out.License, config.License) + } +} |
