diff --git a/frontend/components/AddToCalendar.vue b/frontend/components/AddToCalendar.vue
new file mode 100644
index 0000000..0bfb301
--- /dev/null
+++ b/frontend/components/AddToCalendar.vue
@@ -0,0 +1,74 @@
+
+
+
+
+ Add to calendar
+
Need to change something? You have {{ editsRemaining }} @@ -193,6 +213,13 @@ const submitLabel = computed(() => { with +{{ existing.plus_ones }} plus-ones on {{ fmtDate(existing.submitted_at) }}.
+ +{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining. diff --git a/internal/api/server.go b/internal/api/server.go index 627419c..1de6761 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -305,6 +305,11 @@ func (s *Server) Handler() http.Handler { // of their source IP. mux.Handle("GET /access/{token}", rl("access", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.access))) + // Block B: .ics download. Same rate-limit class as /access since the + // payload is similarly cheap and the abuse profile is identical (one + // token per attacker). + mux.Handle("GET /access/{token}/calendar.ics", + rl("calendar_ics", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.calendar))) mux.Handle("POST /rsvp/{token}", rl("rsvp", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.submit))) // Block A: edits are bounded by MaxRSVPEdits server-side. The redis diff --git a/internal/api/tokens.go b/internal/api/tokens.go index 3e3bfba..89322d6 100644 --- a/internal/api/tokens.go +++ b/internal/api/tokens.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/auth" + "github.com/alchemistkay/guestguard/internal/calendar" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/natspub" "github.com/alchemistkay/guestguard/internal/storage" @@ -410,6 +411,10 @@ type accessResponse struct { // page can show an edit form instead of a fresh submit form when the // guest revisits their invitation link (Tier 2 Block A). RSVP *domain.RSVP `json:"rsvp,omitempty"` + // Calendar holds the add-to-calendar deep-links and the .ics download + // path so the frontend renders four ready-to-click buttons after a + // successful RSVP. (Tier 2 Block B.) + Calendar calendar.ProviderLinks `json:"calendar"` } // GET /access/{token} — validate token, log the access attempt, publish to NATS. @@ -498,9 +503,79 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) { Token: tk, AccessLog: accessLogID, RSVP: existingRSVP, + Calendar: h.calendarLinks(event, raw), }) } +// calendarLinks renders the three provider URLs + .ics path for the event. +// The raw access token is embedded in the .ics path so the download endpoint +// stays public (no auth) while still scoped to a single invitation. +func (h *tokenHandler) calendarLinks(event *domain.Event, rawToken string) calendar.ProviderLinks { + return calendar.BuildLinks(eventForCalendar(event), h.calendarICSURL(rawToken)) +} + +func (h *tokenHandler) calendarICSURL(rawToken string) string { + base := h.publicBaseURL + if base == "" { + base = "http://localhost:8080" + } + return base + "/access/" + rawToken + "/calendar.ics" +} + +func eventForCalendar(e *domain.Event) calendar.Event { + // We deliberately don't surface event Settings into the calendar entry — + // settings holds host-private config; the guest only needs the + // human-facing fields. + return calendar.Event{ + ID: e.ID, + Name: e.Name, + Venue: e.Venue, + StartsAt: e.EventDate, + } +} + +// GET /access/{token}/calendar.ics — public, token-scoped download. Same +// token-validity rules as /access/{token}, but the response is the .ics +// file rather than JSON. +func (h *tokenHandler) calendar(w http.ResponseWriter, r *http.Request) { + raw := r.PathValue("token") + if err := auth.ValidateFormat(raw); err != nil { + writeError(w, http.StatusBadRequest, "malformed token") + return + } + tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw)) + if err != nil { + if errors.Is(err, domain.ErrTokenNotFound) { + writeError(w, http.StatusNotFound, "token not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load token") + return + } + if err := tk.IsValid(time.Now().UTC()); err != nil { + writeError(w, http.StatusGone, err.Error()) + return + } + guest, err := h.guests.Get(r.Context(), tk.GuestID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load guest") + return + } + event, err := h.events.Get(r.Context(), guest.EventID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load event") + return + } + + ics := calendar.BuildICS(eventForCalendar(event), time.Now().UTC()) + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") + w.Header().Set("Content-Disposition", + `attachment; filename="`+calendar.FileName(event.Name)+`"`) + w.Header().Set("Cache-Control", "private, no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(ics)) +} + func collectFingerprint(r *http.Request) map[string]any { fp := map[string]any{ "user_agent": r.UserAgent(), diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go new file mode 100644 index 0000000..94ee50b --- /dev/null +++ b/internal/calendar/calendar.go @@ -0,0 +1,204 @@ +// Package calendar builds the "add to calendar" artefacts shown on the RSVP +// confirmation flow: an RFC 5545 iCalendar (.ics) file, plus the three +// well-known provider deep-links (Google, Outlook, Yahoo). Tier 2 Block B. +// +// The .ics is the source of truth — Google/Outlook/Yahoo URLs are pure URL +// builders and don't need a server round-trip, but we serve them anyway so +// the frontend doesn't have to reimplement the encoding rules per provider. +package calendar + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/google/uuid" +) + +// DefaultDuration is the assumed duration of an event when the host hasn't +// told us when it ends. Two hours covers most weddings, parties, and dinners +// without being so long it pollutes the guest's calendar if they don't tweak +// it themselves. +const DefaultDuration = 2 * time.Hour + +// Event is the projection of domain.Event needed to render calendar artefacts. +// Kept narrow so this package doesn't depend on the broader domain model and +// can be unit-tested with synthetic data. +type Event struct { + ID uuid.UUID + Name string + Venue string + Description string + StartsAt time.Time + // EndsAt is optional. When zero, DefaultDuration is added to StartsAt. + EndsAt time.Time +} + +// ProviderLinks bundles the three add-to-calendar URLs plus the path to +// download the .ics. Embedded in the /access/{token} response so the +// frontend renders four ready-made buttons. +type ProviderLinks struct { + Google string `json:"google"` + Outlook string `json:"outlook"` + Yahoo string `json:"yahoo"` + ICS string `json:"ics"` +} + +// BuildLinks renders the four add-to-calendar URLs for the event. ICS is the +// publicly-reachable path on this server (typically /access/{token}/calendar.ics). +func BuildLinks(e Event, icsURL string) ProviderLinks { + start, end := e.window() + + gq := url.Values{} + gq.Set("action", "TEMPLATE") + gq.Set("text", e.Name) + gq.Set("dates", fmt.Sprintf("%s/%s", fmtUTC(start), fmtUTC(end))) + if e.Description != "" { + gq.Set("details", e.Description) + } + if e.Venue != "" { + gq.Set("location", e.Venue) + } + + oq := url.Values{} + oq.Set("path", "/calendar/action/compose") + oq.Set("rru", "addevent") + oq.Set("subject", e.Name) + oq.Set("startdt", start.UTC().Format(time.RFC3339)) + oq.Set("enddt", end.UTC().Format(time.RFC3339)) + if e.Description != "" { + oq.Set("body", e.Description) + } + if e.Venue != "" { + oq.Set("location", e.Venue) + } + + yq := url.Values{} + yq.Set("v", "60") + yq.Set("title", e.Name) + yq.Set("st", fmtUTC(start)) + yq.Set("et", fmtUTC(end)) + if e.Description != "" { + yq.Set("desc", e.Description) + } + if e.Venue != "" { + yq.Set("in_loc", e.Venue) + } + + return ProviderLinks{ + Google: "https://www.google.com/calendar/render?" + gq.Encode(), + Outlook: "https://outlook.live.com/calendar/0/deeplink/compose?" + oq.Encode(), + Yahoo: "https://calendar.yahoo.com/?" + yq.Encode(), + ICS: icsURL, + } +} + +// BuildICS renders a minimal RFC 5545 VCALENDAR with a single VEVENT. +// The UID is deterministic — re-downloading replaces the same calendar +// entry instead of creating a duplicate. Timestamps are emitted in UTC +// (Z-suffixed); we don't bother with VTIMEZONE blocks because every modern +// client handles UTC correctly and the host already chose a specific +// instant in `event_date`. +// +// `now` is injected for deterministic testing — pass time.Now().UTC() in +// production. +func BuildICS(e Event, now time.Time) string { + start, end := e.window() + var b strings.Builder + b.Grow(512) + write := func(lines ...string) { + for _, l := range lines { + b.WriteString(l) + b.WriteString("\r\n") + } + } + + write( + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//GuestGuard//RSVP//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "BEGIN:VEVENT", + "UID:"+uidFor(e.ID), + "DTSTAMP:"+fmtUTC(now.UTC()), + "DTSTART:"+fmtUTC(start), + "DTEND:"+fmtUTC(end), + "SUMMARY:"+escape(e.Name), + ) + if e.Description != "" { + write("DESCRIPTION:" + escape(e.Description)) + } + if e.Venue != "" { + write("LOCATION:" + escape(e.Venue)) + } + write( + "END:VEVENT", + "END:VCALENDAR", + ) + return b.String() +} + +// FileName slugifies an event name into something safe for the +// Content-Disposition header. Falls back to "event.ics" when nothing usable +// is left after slugification. +func FileName(name string) string { + out := make([]byte, 0, len(name)) + for _, r := range strings.ToLower(name) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + out = append(out, byte(r)) + case r == ' ', r == '-', r == '_': + out = append(out, '-') + } + } + // Collapse repeated dashes. + for strings.Contains(string(out), "--") { + out = []byte(strings.ReplaceAll(string(out), "--", "-")) + } + s := strings.Trim(string(out), "-") + if s == "" { + return "event.ics" + } + return s + ".ics" +} + +func (e Event) window() (start, end time.Time) { + start = e.StartsAt.UTC() + if e.EndsAt.IsZero() { + end = start.Add(DefaultDuration) + } else { + end = e.EndsAt.UTC() + } + return +} + +// uidFor produces a deterministic UID for an event. The @guestguard.k4scloud +// suffix is required by RFC 5545 ("globally unique identifier") and ties the +// UID to our origin so unrelated systems don't accidentally collide. +func uidFor(id uuid.UUID) string { + return id.String() + "@guestguard.k4scloud" +} + +// fmtUTC encodes a time as the basic-form UTC stamp the providers expect: +// "20260517T180000Z". time.Format with a literal layout avoids the locale +// surprises of time.RFC* constants. +func fmtUTC(t time.Time) string { + return t.UTC().Format("20060102T150405Z") +} + +// escape applies the small set of TEXT-value substitutions RFC 5545 calls +// out. Without these, a comma in a venue name silently truncates the field +// in some readers. +var textReplacer = strings.NewReplacer( + `\`, `\\`, + "\n", `\n`, + "\r", "", + ",", `\,`, + ";", `\;`, +) + +func escape(s string) string { + return textReplacer.Replace(s) +} diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go new file mode 100644 index 0000000..3a95198 --- /dev/null +++ b/internal/calendar/calendar_test.go @@ -0,0 +1,143 @@ +package calendar + +import ( + "net/url" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +var sampleID = uuid.MustParse("11111111-2222-3333-4444-555555555555") + +func sampleEvent() Event { + return Event{ + ID: sampleID, + Name: "Kwaku & Ama's Wedding", + Venue: "The Mansfield, 11 W 26th St, New York, NY", + Description: "Drinks at 6, ceremony at 7. Wear comfortable shoes; the rooftop has uneven cobbles.", + StartsAt: time.Date(2026, 6, 12, 18, 0, 0, 0, time.UTC), + } +} + +func TestBuildICS_RequiredFields(t *testing.T) { + now := time.Date(2026, 5, 17, 9, 30, 0, 0, time.UTC) + got := BuildICS(sampleEvent(), now) + + wantSubstrings := []string{ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//GuestGuard//RSVP//EN", + "BEGIN:VEVENT", + "UID:11111111-2222-3333-4444-555555555555@guestguard.k4scloud", + "DTSTAMP:20260517T093000Z", + "DTSTART:20260612T180000Z", + // Default 2h duration when EndsAt is zero. + "DTEND:20260612T200000Z", + // Comma + apostrophe in name; the comma is escaped, the apostrophe is not. + `SUMMARY:Kwaku & Ama's Wedding`, + "END:VEVENT", + "END:VCALENDAR", + } + for _, want := range wantSubstrings { + if !strings.Contains(got, want) { + t.Errorf("ics missing %q in output:\n%s", want, got) + } + } +} + +func TestBuildICS_EscapesText(t *testing.T) { + e := sampleEvent() + now := time.Date(2026, 5, 17, 9, 30, 0, 0, time.UTC) + got := BuildICS(e, now) + // Venue has a comma — must be escaped per RFC 5545. + if !strings.Contains(got, `LOCATION:The Mansfield\, 11 W 26th St\, New York\, NY`) { + t.Errorf("expected escaped commas in LOCATION; got:\n%s", got) + } + // Description has a semicolon — also must be escaped. + if !strings.Contains(got, `DESCRIPTION:Drinks at 6\, ceremony at 7. Wear comfortable shoes\; the rooftop has uneven cobbles.`) { + t.Errorf("expected escaped semicolon in DESCRIPTION; got:\n%s", got) + } +} + +func TestBuildICS_LineEndings(t *testing.T) { + got := BuildICS(sampleEvent(), time.Now().UTC()) + // RFC 5545 requires CRLF line endings. + if !strings.Contains(got, "\r\n") { + t.Error("expected CRLF line endings") + } + if strings.Contains(got, "\n\n") { + t.Error("found bare LFs — must be CRLF") + } +} + +func TestBuildICS_HonoursExplicitEnd(t *testing.T) { + e := sampleEvent() + e.EndsAt = time.Date(2026, 6, 12, 23, 30, 0, 0, time.UTC) + got := BuildICS(e, time.Now().UTC()) + if !strings.Contains(got, "DTEND:20260612T233000Z") { + t.Errorf("explicit end not honoured: %s", got) + } +} + +func TestBuildICS_OmitsEmptyOptionals(t *testing.T) { + e := Event{ + ID: sampleID, + Name: "Quiet event", + StartsAt: time.Date(2026, 6, 12, 18, 0, 0, 0, time.UTC), + } + got := BuildICS(e, time.Now().UTC()) + if strings.Contains(got, "LOCATION:") { + t.Error("LOCATION should be omitted when venue is empty") + } + if strings.Contains(got, "DESCRIPTION:") { + t.Error("DESCRIPTION should be omitted when description is empty") + } +} + +func TestBuildLinks_GoogleEncodesDateRange(t *testing.T) { + links := BuildLinks(sampleEvent(), "https://example/access/abc/calendar.ics") + u, err := url.Parse(links.Google) + if err != nil { + t.Fatalf("parse google link: %v", err) + } + if u.Host != "www.google.com" || u.Path != "/calendar/render" { + t.Errorf("unexpected google host/path: %s%s", u.Host, u.Path) + } + dates := u.Query().Get("dates") + if dates != "20260612T180000Z/20260612T200000Z" { + t.Errorf("google dates: got %q want 20260612T180000Z/20260612T200000Z", dates) + } + if u.Query().Get("text") != "Kwaku & Ama's Wedding" { + t.Errorf("google text not preserved: %q", u.Query().Get("text")) + } +} + +func TestBuildLinks_OutlookYahooShape(t *testing.T) { + links := BuildLinks(sampleEvent(), "https://example/ics") + if !strings.HasPrefix(links.Outlook, "https://outlook.live.com/calendar/0/deeplink/compose?") { + t.Errorf("outlook link wrong prefix: %s", links.Outlook) + } + if !strings.HasPrefix(links.Yahoo, "https://calendar.yahoo.com/?") { + t.Errorf("yahoo link wrong prefix: %s", links.Yahoo) + } + if links.ICS != "https://example/ics" { + t.Errorf("ICS url not echoed back: %s", links.ICS) + } +} + +func TestFileName(t *testing.T) { + cases := map[string]string{ + "Kwaku & Ama's Wedding": "kwaku-amas-wedding.ics", + " Birthday Party!! ": "birthday-party.ics", + "---": "event.ics", + "": "event.ics", + "Hack/Day 2026": "hackday-2026.ics", + } + for in, want := range cases { + if got := FileName(in); got != want { + t.Errorf("FileName(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index b8d2751..86d9098 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -12,6 +12,7 @@ import ( "net" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -317,6 +318,74 @@ func TestE2EHappyPath(t *testing.T) { }, http.StatusNotFound) assertNoRSVP(t, ctx, db.Pool, guestID) }) + + // Tier 2 Block B — calendar integration. + t.Run("access response embeds calendar provider links", func(t *testing.T) { + eventID := createEvent(t, srv.URL, hostToken, "Calendar Test", "calendar-test") + guestID := createGuest(t, srv.URL, hostToken, eventID, "Cal Guest") + token := issueToken(t, srv.URL, hostToken, eventID, guestID) + + access := getAccessFull(t, srv.URL, token) + if access.Calendar.ICS == "" { + t.Fatalf("expected calendar.ics URL in /access response: %+v", access.Calendar) + } + if !strings.HasSuffix(access.Calendar.ICS, "/access/"+token+"/calendar.ics") { + t.Errorf("ics url not token-scoped: %s", access.Calendar.ICS) + } + if !strings.HasPrefix(access.Calendar.Google, "https://www.google.com/calendar/render?") { + t.Errorf("google link wrong: %s", access.Calendar.Google) + } + if !strings.HasPrefix(access.Calendar.Outlook, "https://outlook.live.com/") { + t.Errorf("outlook link wrong: %s", access.Calendar.Outlook) + } + }) + + t.Run("ics download is a valid VCALENDAR with the event name", func(t *testing.T) { + eventID := createEvent(t, srv.URL, hostToken, "Brunch & Beans", "brunch-beans") + guestID := createGuest(t, srv.URL, hostToken, eventID, "ICS Guest") + token := issueToken(t, srv.URL, hostToken, eventID, guestID) + + resp, err := http.Get(srv.URL + "/access/" + token + "/calendar.ics") + must(t, err, "GET .ics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("ics status=%d body=%s", resp.StatusCode, body) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") { + t.Errorf("ics content-type: %q", ct) + } + cd := resp.Header.Get("Content-Disposition") + if !strings.Contains(cd, "filename=") || !strings.Contains(cd, "brunch-beans.ics") { + t.Errorf("ics content-disposition: %q", cd) + } + body, err := io.ReadAll(resp.Body) + must(t, err, "read ics body") + text := string(body) + for _, want := range []string{ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "BEGIN:VEVENT", + "UID:" + eventID.String() + "@guestguard.k4scloud", + "SUMMARY:Brunch & Beans", + "END:VEVENT", + "END:VCALENDAR", + } { + if !strings.Contains(text, want) { + t.Errorf("ics missing %q in:\n%s", want, text) + } + } + }) + + t.Run("ics download rejects unknown token", func(t *testing.T) { + resp, err := http.Get(srv.URL + "/access/tk_nonexistenttoken1234567890ab/calendar.ics") + must(t, err, "GET .ics unknown") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 404, got %d body=%s", resp.StatusCode, body) + } + }) } // --- container helpers --- @@ -536,6 +605,12 @@ type accessResponseFull struct { PlusOnes int `json:"plus_ones"` EditCount int `json:"edit_count"` } `json:"rsvp"` + Calendar struct { + Google string `json:"google"` + Outlook string `json:"outlook"` + Yahoo string `json:"yahoo"` + ICS string `json:"ics"` + } `json:"calendar"` } func getAccessFull(t *testing.T, base, token string) accessResponseFull {