package web import ( "bytes" "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "net/http" "regexp" "sort" "strconv" "strings" "time" ical "github.com/emersion/go-ical" "github.com/yourusername/caldav-server/internal/birthdays" "github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/icalfix" "github.com/yourusername/caldav-server/internal/web/templates" ) // eventIDRe validates an event's object ID as it appears in a URL path // segment: a filename like "3f9a2b8c1d4e5f60ab12cd34ef56ab78.ics". var eventIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}\.ics$`) // dateLayout/timeLayout are the HTML date/time input formats used // throughout the event form. const dateLayout = "2006-01-02" const timeLayout = "15:04" // calRefSep separates the owner and calendar name in a shared calendar's // reference string (e.g. "alice~vacations"), mirroring (but independent // from) internal/caldav's sharedNameSep convention for its own URL // namespace. const calRefSep = "~" // errCalendarNotFound/errCalendarReadOnly are sentinel errors returned by // resolveCalRef, translated by callers into 404/403 responses. var ( errCalendarNotFound = errors.New("calendar not found") errCalendarReadOnly = errors.New("calendar is read-only") ) // newEventID generates a random filename for a new event object, // mirroring the ".ics" convention used by the CalDAV backend // (internal/caldav). func newEventID() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf) + ".ics", nil } // sharedCalRef builds the reference string used in URLs for a calendar // shared with the requesting user by owner. func sharedCalRef(owner, name string) string { return owner + calRefSep + name } // ownsCalendar reports whether cal is one of username's own calendars. func (s *Server) ownsCalendar(username, cal string) (bool, error) { if !resourceNameRe.MatchString(cal) { return false, nil } cals, err := s.dbase.ListCalendars(username) if err != nil { return false, err } for _, c := range cals { if c.Name == cal { return true, nil } } return false, nil } // resolveCalRef resolves a calendar reference (either a bare name for // username's own calendar, or "~" for a calendar shared with // username) into the concrete (owner, name) pair to use when addressing // store.Store/db.DB. If requireWrite is true, a read-only share is // rejected with errCalendarReadOnly. func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) { if ref == birthdaysCalRef || strings.HasPrefix(ref, icsRefPrefix) { // Both the birthdays calendar and ICS subscriptions are // virtual/computed, not backed by any stored calendar object — // there's nothing to resolve to. return "", "", errCalendarNotFound } if owner, name, ok := strings.Cut(ref, calRefSep); ok { if !resourceNameRe.MatchString(owner) || !resourceNameRe.MatchString(name) { return "", "", errCalendarNotFound } share, err := s.dbase.CalendarShareFor(owner, name, username) if err != nil { if errors.Is(err, db.ErrShareNotFound) { return "", "", errCalendarNotFound } return "", "", err } if requireWrite && share.Permission != db.PermWrite { return "", "", errCalendarReadOnly } return owner, name, nil } ok, err := s.ownsCalendar(username, ref) if err != nil { return "", "", err } if !ok { return "", "", errCalendarNotFound } return username, ref, nil } // calendarEntry is one calendar (own or shared) visible to a user in the // combined month view. type calendarEntry struct { Ref string // path-safe reference: "name" or "owner~name" Owner string // owner's username Name string // calendar's own name (unqualified) Color string Writable bool Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects ICSURL string // set only for ICS-subscription entries (Ref has icsRefPrefix); the remote URL to fetch events from } // birthdaysCalRef is the fixed reference for the synthetic "Birthdays" // calendar. It deliberately can't collide with a real calendar's ref: "@" // is not in resourceNameRe's character class (so it can't be a bare own // calendar name) and it contains no calRefSep ("~") (so it can't be // mistaken for an "owner~name" shared-calendar ref either). const birthdaysCalRef = "@birthdays" // icsRefPrefix marks a calendarEntry's Ref as referring to one of // username's own ICS/webcal subscriptions (see internal/db/ics.go). Like // "@" for the birthdays calendar, "!" isn't in resourceNameRe's character // class and can't appear in a calRefSep-joined shared-calendar ref either, // so "!" can't collide with any other kind of ref. const icsRefPrefix = "!" // icsCalRef builds the reference string for one of username's own ICS // subscriptions named name. func icsCalRef(name string) string { return icsRefPrefix + name } // defaultBirthdayColor is the display color for the virtual birthdays // calendar used until the user picks their own from the dashboard (a // pink, distinct from typical user-picked calendar colors). const defaultBirthdayColor = "#ec4899" // birthdayCalendarColor returns username's chosen color for the virtual // birthdays calendar, falling back to defaultBirthdayColor if unset. func (s *Server) birthdayCalendarColor(username string) string { color, err := s.dbase.GetBirthdayCalendarColor(username) if err != nil || color == "" { return defaultBirthdayColor } return color } // listCalendarEntries returns every calendar visible to username: the // synthetic birthdays calendar, their own calendars (always writable), // any calendars shared with them (writable only if the share grants write // permission), and their own read-only ICS/webcal subscriptions. func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) { entries := []calendarEntry{ {Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true}, } own, err := s.dbase.ListCalendars(username) if err != nil { return nil, err } for _, c := range own { entries = append(entries, calendarEntry{ Ref: c.Name, Owner: username, Name: c.Name, Color: c.Color, Writable: true, }) } shared, err := s.dbase.CalendarsSharedWith(username) if err != nil { return nil, err } for _, sh := range shared { color, err := s.dbase.GetCalendarColor(sh.Owner, sh.CalendarName) if err != nil { color = "" } entries = append(entries, calendarEntry{ Ref: sharedCalRef(sh.Owner, sh.CalendarName), Owner: sh.Owner, Name: sh.CalendarName, Color: color, Writable: sh.Permission == db.PermWrite, }) } subs, err := s.dbase.ListICSSubscriptions(username) if err != nil { return nil, err } for _, sub := range subs { entries = append(entries, calendarEntry{ Ref: icsCalRef(sub.Name), Owner: username, Name: sub.Name, Color: sub.Color, Writable: false, Virtual: true, ICSURL: sub.URL, }) } sort.Slice(entries, func(i, j int) bool { if entries[i].Owner != entries[j].Owner { return entries[i].Owner < entries[j].Owner } return entries[i].Name < entries[j].Name }) return entries, nil } func (s *Server) handleCalendarMonth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) year, month := parseYearMonth(r) data, err := s.buildMonthView(username, year, month) if err != nil { s.logger.Error("building month view", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.MonthView(username, data).Render(context.Background(), w) } // parseYearMonth reads ?year=&month= query params, defaulting to the // current month if absent or invalid. func parseYearMonth(r *http.Request) (year int, month time.Month) { now := time.Now() year, month = now.Year(), now.Month() if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y >= 1 && y <= 9999 { year = y } if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 { month = time.Month(m) } return year, month } // handleCalendarWeek renders the week view, mirroring // handleCalendarMonth but for a single 7-day week. func (s *Server) handleCalendarWeek(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) weekStart := parseWeekStart(r) data, err := s.buildWeekView(username, weekStart) if err != nil { s.logger.Error("building week view", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.WeekView(username, data).Render(context.Background(), w) } // parseWeekStart reads ?date=YYYY-MM-DD and returns the Monday of that // date's week, defaulting to the current week if the parameter is // absent or invalid. func parseWeekStart(r *http.Request) time.Time { if v := r.URL.Query().Get("date"); v != "" { if d, err := time.ParseInLocation(dateLayout, v, time.Local); err == nil { return mondayOf(d) } } return mondayOf(time.Now()) } // buildMonthView loads every event from every calendar visible to // username (own + shared), then places each occurrence's days onto a // 6-week grid covering the requested month (plus enough leading/trailing // days of neighboring months to fill full weeks). Recurring events // (RRULE) are not expanded — only an event's own DTSTART/DTEND span is // considered. func (s *Server) buildMonthView(username string, year int, month time.Month) (templates.MonthViewData, error) { loc := time.Local first := time.Date(year, month, 1, 0, 0, 0, 0, loc) // Monday-first offset: time.Weekday has Sunday=0..Saturday=6. offset := (int(first.Weekday()) + 6) % 7 gridStart := first.AddDate(0, 0, -offset) const totalDays = 42 // 6 weeks today := time.Now().In(loc).Format(dateLayout) days := make([]templates.MonthDay, totalDays) for i := 0; i < totalDays; i++ { d := gridStart.AddDate(0, 0, i) dateStr := d.Format(dateLayout) days[i] = templates.MonthDay{ Date: dateStr, Day: d.Day(), InMonth: d.Month() == month, IsToday: dateStr == today, } } gridEnd := gridStart.AddDate(0, 0, totalDays-1) eventsByDay, calSummaries, hasWritable, err := s.collectCalendarEvents(username, gridStart, gridEnd, loc) if err != nil { return templates.MonthViewData{}, err } for i := range days { days[i].Events = sortedDayEvents(eventsByDay[days[i].Date]) } weeks := make([][]templates.MonthDay, 0, 6) for i := 0; i < totalDays; i += 7 { weeks = append(weeks, days[i:i+7]) } prevMonth := first.AddDate(0, -1, 0) nextMonth := first.AddDate(0, 1, 0) monthURL := func(y int, m time.Month) string { return fmt.Sprintf("/web/calendar?year=%d&month=%d", y, int(m)) } return templates.MonthViewData{ Calendars: calSummaries, HasWritable: hasWritable, MonthLabel: first.Format("January 2006"), Weeks: weeks, PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()), NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()), TodayURL: "/web/calendar", WeekURL: "/web/calendar/week", }, nil } // buildWeekView loads every event from every calendar visible to // username (own + shared) that falls within the 7-day week starting on // weekStart (which must already be a Monday — see mondayOf), placing // each occurrence's days onto a single 7-column row. func (s *Server) buildWeekView(username string, weekStart time.Time) (templates.WeekViewData, error) { loc := time.Local gridEnd := weekStart.AddDate(0, 0, 6) today := time.Now().In(loc).Format(dateLayout) eventsByDay, calSummaries, hasWritable, err := s.collectCalendarEvents(username, weekStart, gridEnd, loc) if err != nil { return templates.WeekViewData{}, err } days := make([]templates.WeekDay, 7) for i := 0; i < 7; i++ { d := weekStart.AddDate(0, 0, i) dateStr := d.Format(dateLayout) days[i] = templates.WeekDay{ Date: dateStr, Weekday: d.Format("Monday"), Day: d.Day(), Month: d.Format("Jan"), IsToday: dateStr == today, Events: sortedDayEvents(eventsByDay[dateStr]), } } prevWeek := weekStart.AddDate(0, 0, -7) nextWeek := weekStart.AddDate(0, 0, 7) weekURL := func(t time.Time) string { return "/web/calendar/week?date=" + t.Format(dateLayout) } rangeLabel := fmt.Sprintf("%s – %s", weekStart.Format("Jan 2"), gridEnd.Format("Jan 2, 2006")) return templates.WeekViewData{ Calendars: calSummaries, HasWritable: hasWritable, RangeLabel: rangeLabel, Days: days, PrevWeekURL: weekURL(prevWeek), NextWeekURL: weekURL(nextWeek), TodayURL: "/web/calendar/week", MonthURL: "/web/calendar", }, nil } // sortedDayEvents returns evs (which may be nil) sorted with all-day // events first, then timed events by start time — the stable, readable // order used by both the month and week grids. func sortedDayEvents(evs []templates.EventSummary) []templates.EventSummary { sort.SliceStable(evs, func(a, b int) bool { if evs[a].AllDay != evs[b].AllDay { return evs[a].AllDay } return evs[a].TimeText < evs[b].TimeText }) return evs } // collectCalendarEvents gathers every event, across every calendar // visible to username (own, shared, birthdays, ICS subscriptions), whose // day-span overlaps [gridStart, gridEnd] (inclusive, in loc), keyed by // day (dateLayout) so callers can place them into either a month or a // week grid. It also returns the calendar summary list (used for the // legend/sidebar) and whether any calendar is writable (for "new event" // links). func (s *Server) collectCalendarEvents(username string, gridStart, gridEnd time.Time, loc *time.Location) (eventsByDay map[string][]templates.EventSummary, calSummaries []templates.CalendarSummary, hasWritable bool, err error) { entries, err := s.listCalendarEntries(username) if err != nil { return nil, nil, false, err } // Cap how many days any single event can add, in case of malformed // data with a wildly distant DTEND. totalDays := int(gridEnd.Sub(gridStart).Hours()/24) + 1 dayIndex := make(map[string]int, totalDays) days := make([]templates.MonthDay, totalDays) for i := 0; i < totalDays; i++ { d := gridStart.AddDate(0, 0, i) dateStr := d.Format(dateLayout) days[i] = templates.MonthDay{Date: dateStr} dayIndex[dateStr] = i } eventsByDay = make(map[string][]templates.EventSummary) for _, entry := range entries { if entry.Writable { hasWritable = true } calSummaries = append(calSummaries, templates.CalendarSummary{ Ref: entry.Ref, Name: entry.Name, Owner: s.dbase.DisplayName(entry.Owner), Color: entry.Color, Shared: entry.Owner != username, Writable: entry.Writable, Virtual: entry.Virtual, }) if entry.Ref == birthdaysCalRef { if err := s.addBirthdayEvents(username, entry, gridStart, gridEnd, dayIndex, days); err != nil { s.logger.Warn("computing birthday events", "error", err) } continue } if strings.HasPrefix(entry.Ref, icsRefPrefix) { if err := s.addICSEvents(entry, gridStart, gridEnd, loc, dayIndex, days); err != nil { s.logger.Warn("fetching ics subscription events", "calendar", entry.Name, "error", err) } continue } ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name) if err != nil { continue } for _, id := range ids { data, err := s.store.GetObject(entry.Owner, "cal-"+entry.Name, id) if err != nil { continue } form, err := eventFormFromICS(id, data) if err != nil { s.logger.Warn("decoding calendar object", "calendar", entry.Name, "id", id, "error", err) continue } startDay, endDay, err := eventDayRange(form, loc) if err != nil { continue } if endDay.Before(gridStart) || startDay.After(gridEnd) { continue } if startDay.Before(gridStart) { startDay = gridStart } if endDay.After(gridEnd) { endDay = gridEnd } for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 { idx, ok := dayIndex[d.Format(dateLayout)] if !ok { continue } timeText := "" if !form.AllDay { timeText = form.StartTime } days[idx].Events = append(days[idx].Events, templates.EventSummary{ ID: id, CalRef: entry.Ref, Color: entry.Color, Summary: form.Summary, TimeText: timeText, AllDay: form.AllDay, }) } } } for _, d := range days { if len(d.Events) > 0 { eventsByDay[d.Date] = d.Events } } return eventsByDay, calSummaries, hasWritable, nil } // mondayOf returns the Monday (in loc, at midnight) of the week // containing t. func mondayOf(t time.Time) time.Time { // time.Weekday has Sunday=0..Saturday=6; convert to a Monday-first // offset so Monday itself maps to 0. offset := (int(t.Weekday()) + 6) % 7 return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset) } // eventDayRange returns the inclusive [start, end] calendar-day span an // event occupies, in loc, for placing it on the month grid. func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) { start, err = time.ParseInLocation(dateLayout, form.StartDate, loc) if err != nil { return time.Time{}, time.Time{}, err } endDate := form.EndDate if endDate == "" { endDate = form.StartDate } end, err = time.ParseInLocation(dateLayout, endDate, loc) if err != nil { return time.Time{}, time.Time{}, err } if end.Before(start) { end = start } return start, end, nil } func (s *Server) handleEventNew(w http.ResponseWriter, r *http.Request) { username := userFromContext(r.Context()) entries, err := s.listCalendarEntries(username) if err != nil { s.logger.Error("listing calendars", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } var options []templates.CalendarOption for _, e := range entries { if !e.Writable { continue } label := e.Name if e.Owner != username { label = e.Name + " (" + s.dbase.DisplayName(e.Owner) + ")" } options = append(options, templates.CalendarOption{Ref: e.Ref, Label: label}) } if len(options) == 0 { http.Error(w, "you don't have any calendar you can add events to", http.StatusConflict) return } switch r.Method { case http.MethodGet: selected := r.URL.Query().Get("calendar") if selected == "" { selected = options[0].Ref } form := newEventFormDefaults(selected, r.URL.Query().Get("date")) form.Calendars = options w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.EventForm(username, form, "").Render(context.Background(), w) case http.MethodPost: if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } ref := strings.TrimSpace(r.PostForm.Get("calendar")) owner, name, err := s.resolveCalRef(username, ref, true) if err != nil { s.handleCalRefError(w, r, err) return } s.saveEventFromForm(w, r, owner, name, ref, "", options) default: w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // handleCalRefError translates a resolveCalRef error into the // appropriate HTTP response. func (s *Server) handleCalRefError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, errCalendarNotFound): http.NotFound(w, r) case errors.Is(err, errCalendarReadOnly): http.Error(w, "this calendar is shared read-only", http.StatusForbidden) default: s.logger.Error("resolving calendar reference", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) } } // newEventFormDefaults builds the initial form values for a brand new // event. If dateParam is a valid "YYYY-MM-DD" (e.g. from clicking a day // cell in the month grid), the event defaults to an all-day event on that // date; otherwise it defaults to a one-hour timed event starting at the // next full hour today. func newEventFormDefaults(calRef, dateParam string) templates.EventFormData { if _, err := time.Parse(dateLayout, dateParam); err == nil { return templates.EventFormData{CalRef: calRef, AllDay: true, StartDate: dateParam, EndDate: dateParam} } now := time.Now() start := now.Truncate(time.Hour).Add(time.Hour) end := start.Add(time.Hour) return templates.EventFormData{ CalRef: calRef, StartDate: start.Format(dateLayout), StartTime: start.Format(timeLayout), EndDate: end.Format(dateLayout), EndTime: end.Format(timeLayout), } } func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) { username := userFromContext(r.Context()) ref := r.PathValue("ref") id := r.PathValue("id") owner, name, err := s.resolveCalRef(username, ref, false) if err != nil { s.handleCalRefError(w, r, err) return } if !eventIDRe.MatchString(id) { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: data, err := s.store.GetObject(owner, "cal-"+name, id) if err != nil { http.NotFound(w, r) return } form, err := eventFormFromICS(id, data) if err != nil { s.logger.Error("decoding event", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } form.CalRef = ref label := name if owner != username { label = name + " (" + s.dbase.DisplayName(owner) + ")" } form.CalendarLabel = label form.Writable = owner == username if !form.Writable { if share, err := s.dbase.CalendarShareFor(owner, name, username); err == nil { form.Writable = share.Permission == db.PermWrite } } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.EventForm(username, form, "").Render(context.Background(), w) case http.MethodPost: if owner2, _, err := s.resolveCalRef(username, ref, true); err != nil { s.handleCalRefError(w, r, err) return } else { owner = owner2 } s.saveEventFromForm(w, r, owner, name, ref, id, nil) default: w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleEventDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) ref := r.PathValue("ref") id := r.PathValue("id") owner, name, err := s.resolveCalRef(username, ref, true) if err != nil { s.handleCalRefError(w, r, err) return } if !eventIDRe.MatchString(id) { http.NotFound(w, r) return } if err := s.store.DeleteObject(owner, "cal-"+name, id); err != nil { http.NotFound(w, r) return } http.Redirect(w, r, "/web/calendar", http.StatusSeeOther) } // eventFormInput holds the parsed, not-yet-validated values submitted by // the event form. type eventFormInput struct { Summary string Description string Location string AllDay bool StartDate string StartTime string EndDate string EndTime string } func parseEventForm(r *http.Request) eventFormInput { return eventFormInput{ Summary: strings.TrimSpace(r.PostForm.Get("summary")), Description: strings.TrimSpace(r.PostForm.Get("description")), Location: strings.TrimSpace(r.PostForm.Get("location")), AllDay: r.PostForm.Get("all_day") != "", StartDate: strings.TrimSpace(r.PostForm.Get("start_date")), StartTime: strings.TrimSpace(r.PostForm.Get("start_time")), EndDate: strings.TrimSpace(r.PostForm.Get("end_date")), EndTime: strings.TrimSpace(r.PostForm.Get("end_time")), } } // saveEventFromForm handles both event creation (id == "") and editing // (id preserves the existing event's UID). owner/name identify the // resolved (and already permission-checked) storage location; ref is the // calendar reference used for redirect/re-render URLs; options is only // non-nil when creating a new event (to re-render the calendar