feat(tier2): calendar integration — Block B
After confirming attendance (or revisiting an already-attending RSVP),
guests can add the event to Google, Outlook, Apple, or any iCalendar
client with one click.
- internal/calendar package builds an RFC 5545 VCALENDAR plus the three
provider deep-links from a narrow Event projection. Times are emitted
as Z-suffixed UTC; the UID is deterministic (event-uuid@guestguard)
so re-downloads update the same calendar entry instead of duplicating
- GET /access/{token}/calendar.ics returns the .ics with a slugified
filename in Content-Disposition; same rate-limit class as /access
- GET /access/{token} response now embeds google/outlook/yahoo/ics URLs
so the frontend doesn't re-encode per provider
- AddToCalendar.vue renders the four buttons; shown only when the
guest is attending (declined/maybe don't need a calendar entry)
- Unit tests cover ics field presence, RFC 5545 escaping (comma,
semicolon), CRLF line endings, explicit-end handling, omitted
optionals, provider URL shape, filename slugification
- Integration: ics download returns valid VCALENDAR with the event
UID + name; unknown token returns 404; /access embeds the links
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user