package web import ( "context" "crypto/rand" "encoding/hex" "fmt" "net/http" "regexp" "sort" "strconv" "strings" "time" ical "github.com/emersion/go-ical" "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" // 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 } // ownsCalendar reports whether cal is one of username's own calendars // (never one shared with them by another user — the web calendar UI only // manages a user's own calendars, consistent with contacts/files). 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 } func (s *Server) handleCalendarsHome(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()) cals, err := s.dbase.ListCalendars(username) if err != nil { s.logger.Error("listing calendars", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } sort.Slice(cals, func(i, j int) bool { return cals[i].Name < cals[j].Name }) var summaries []templates.CalendarSummary for _, c := range cals { summaries = append(summaries, templates.CalendarSummary{Name: c.Name, Color: c.Color}) } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.CalendarsHome(username, summaries).Render(context.Background(), w) } 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()) cal := r.PathValue("cal") ok, err := s.ownsCalendar(username, cal) if err != nil { s.logger.Error("checking calendar ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } year, month := parseYearMonth(r) color, _ := s.dbase.GetCalendarColor(username, cal) data, err := s.buildMonthView(username, cal, color, year, month) if err != nil { s.logger.Error("building month view", "calendar", cal, "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 } // buildMonthView loads every event in cal, 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, cal, color 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) dayIndex := make(map[string]int, 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, } dayIndex[dateStr] = i } gridEnd := gridStart.AddDate(0, 0, totalDays-1) ids, err := s.store.ListObjects(username, "cal-"+cal) if err != nil { return templates.MonthViewData{}, err } for _, id := range ids { data, err := s.store.GetObject(username, "cal-"+cal, id) if err != nil { continue } form, err := eventFormFromICS(id, data) if err != nil { s.logger.Warn("decoding calendar object", "calendar", cal, "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 } // Cap the number of days a single event can add to the grid, in // case of malformed data with a wildly distant DTEND. 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, Summary: form.Summary, TimeText: timeText, AllDay: form.AllDay, }) } } // Keep each day's events in a stable, readable order: all-day events // first, then timed events sorted by start time. for i := range days { evs := days[i].Events 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 }) } 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/%s?year=%d&month=%d", cal, y, int(m)) } return templates.MonthViewData{ Cal: cal, Color: color, MonthLabel: first.Format("January 2006"), Weeks: weeks, PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()), NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()), TodayURL: "/web/calendar/" + cal, }, nil } // 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()) cal := r.PathValue("cal") ok, err := s.ownsCalendar(username, cal) if err != nil { s.logger.Error("checking calendar ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: form := newEventFormDefaults(cal, r.URL.Query().Get("date")) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.EventForm(username, form, "").Render(context.Background(), w) case http.MethodPost: s.saveEventFromForm(w, r, username, cal, "") default: w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // 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(cal, dateParam string) templates.EventFormData { if _, err := time.Parse(dateLayout, dateParam); err == nil { return templates.EventFormData{Cal: cal, 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{ Cal: cal, 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()) cal := r.PathValue("cal") id := r.PathValue("id") ok, err := s.ownsCalendar(username, cal) if err != nil { s.logger.Error("checking calendar ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok || !eventIDRe.MatchString(id) { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: data, err := s.store.GetObject(username, "cal-"+cal, 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.Cal = cal w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.EventForm(username, form, "").Render(context.Background(), w) case http.MethodPost: s.saveEventFromForm(w, r, username, cal, id) 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()) cal := r.PathValue("cal") id := r.PathValue("id") ok, err := s.ownsCalendar(username, cal) if err != nil { s.logger.Error("checking calendar ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok || !eventIDRe.MatchString(id) { http.NotFound(w, r) return } if err := s.store.DeleteObject(username, "cal-"+cal, id); err != nil { http.NotFound(w, r) return } http.Redirect(w, r, "/web/calendar/"+cal, 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). func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, username, cal, id string) { if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } in := parseEventForm(r) reRender := func(errMsg string) { form := templates.EventFormData{ Cal: cal, ID: id, Summary: in.Summary, Description: in.Description, Location: in.Location, AllDay: in.AllDay, StartDate: in.StartDate, StartTime: in.StartTime, EndDate: in.EndDate, EndTime: in.EndTime, } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.EventForm(username, form, errMsg).Render(context.Background(), w) } if in.Summary == "" { reRender("Title is required") return } start, end, err := computeEventTimes(in) if err != nil { reRender(err.Error()) return } var uid string if id == "" { newID, err := newEventID() if err != nil { s.logger.Error("generating event id", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } id = newID uid = strings.TrimSuffix(id, ".ics") } else { data, err := s.store.GetObject(username, "cal-"+cal, id) if err != nil { http.NotFound(w, r) return } existing, err := ical.NewDecoder(strings.NewReader(string(data))).Decode() if err != nil { s.logger.Error("decoding existing event", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if events := existing.Events(); len(events) > 0 { if uidProp := events[0].Props.Get(ical.PropUID); uidProp != nil { uid = uidProp.Value } } if uid == "" { uid = strings.TrimSuffix(id, ".ics") } } newCal := buildEventCalendar(uid, in, start, end) if err := s.saveEvent(username, cal, id, newCal); err != nil { s.logger.Error("saving event", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/web/calendar/"+cal, http.StatusSeeOther) } // computeEventTimes validates and converts the submitted form fields into // concrete start/end instants (for timed events) or dates (for all-day // events, where end is returned as the exclusive DTEND day). func computeEventTimes(in eventFormInput) (start, end time.Time, err error) { if in.StartDate == "" { return time.Time{}, time.Time{}, fmt.Errorf("start date is required") } endDate := in.EndDate if endDate == "" { endDate = in.StartDate } if in.AllDay { start, err = time.ParseInLocation(dateLayout, in.StartDate, time.Local) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid start date") } end, err = time.ParseInLocation(dateLayout, endDate, time.Local) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid end date") } if end.Before(start) { return time.Time{}, time.Time{}, fmt.Errorf("end date must not be before start date") } // DTEND for all-day events is exclusive: the end date the user // picks is the last day the event covers, so add one day. end = end.AddDate(0, 0, 1) return start, end, nil } if in.StartTime == "" || in.EndTime == "" { return time.Time{}, time.Time{}, fmt.Errorf("start and end time are required for timed events") } start, err = time.ParseInLocation(dateLayout+"T"+timeLayout, in.StartDate+"T"+in.StartTime, time.Local) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid start date/time") } end, err = time.ParseInLocation(dateLayout+"T"+timeLayout, endDate+"T"+in.EndTime, time.Local) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid end date/time") } if !end.After(start) { return time.Time{}, time.Time{}, fmt.Errorf("end must be after start") } return start, end, nil } // buildEventCalendar builds a fresh VCALENDAR/VEVENT from the submitted // form input and resolved start/end instants. func buildEventCalendar(uid string, in eventFormInput, start, end time.Time) *ical.Calendar { cal := ical.NewCalendar() cal.Props.SetText(ical.PropVersion, "2.0") cal.Props.SetText(ical.PropProductID, "-//nidus//web calendar//EN") ev := ical.NewEvent() ev.Props.SetText(ical.PropUID, uid) ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) ev.Props.SetText(ical.PropSummary, in.Summary) if in.Description != "" { ev.Props.SetText(ical.PropDescription, in.Description) } if in.Location != "" { ev.Props.SetText(ical.PropLocation, in.Location) } if in.AllDay { ev.Props.SetDate(ical.PropDateTimeStart, start) ev.Props.SetDate(ical.PropDateTimeEnd, end) } else { ev.Props.SetDateTime(ical.PropDateTimeStart, start.UTC()) ev.Props.SetDateTime(ical.PropDateTimeEnd, end.UTC()) } cal.Children = append(cal.Children, ev.Component) return cal } func (s *Server) saveEvent(username, cal, id string, calendar *ical.Calendar) error { var buf strings.Builder enc := ical.NewEncoder(&buf) if err := enc.Encode(calendar); err != nil { return fmt.Errorf("encoding ical: %w", err) } return s.store.PutObject(username, "cal-"+cal, id, []byte(buf.String())) } // eventFormFromICS decodes a single-VEVENT .ics object into the shared // EventFormData shape used both for pre-filling the edit form and for // placing the event on the month grid. func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) { calendar, err := ical.NewDecoder(strings.NewReader(string(data))).Decode() if err != nil { return templates.EventFormData{}, err } events := calendar.Events() if len(events) == 0 { return templates.EventFormData{}, fmt.Errorf("no VEVENT in %s", id) } ev := events[0] form := templates.EventFormData{ID: id} if p := ev.Props.Get(ical.PropSummary); p != nil { form.Summary = p.Value } if p := ev.Props.Get(ical.PropDescription); p != nil { form.Description = p.Value } if p := ev.Props.Get(ical.PropLocation); p != nil { form.Location = p.Value } startProp := ev.Props.Get(ical.PropDateTimeStart) endProp := ev.Props.Get(ical.PropDateTimeEnd) if startProp == nil { return templates.EventFormData{}, fmt.Errorf("missing DTSTART in %s", id) } allDay := startProp.ValueType() == ical.ValueDate || len(startProp.Value) == 8 // RFC 5545 DATE value, e.g. "20060102" form.AllDay = allDay if allDay { start, err := startProp.DateTime(time.Local) if err != nil { return templates.EventFormData{}, err } form.StartDate = start.Format(dateLayout) end := start.AddDate(0, 0, 1) if endProp != nil { if e, err := endProp.DateTime(time.Local); err == nil { end = e } } // Displayed end date is the last inclusive day (DTEND is exclusive). form.EndDate = end.AddDate(0, 0, -1).Format(dateLayout) } else { // go-ical's Prop.DateTime always parses a "Z"-suffixed UTC value // as UTC regardless of the loc argument passed in, so the // returned time must be explicitly converted to the local zone // before formatting for display. start, err := startProp.DateTime(time.Local) if err != nil { return templates.EventFormData{}, err } start = start.In(time.Local) form.StartDate = start.Format(dateLayout) form.StartTime = start.Format(timeLayout) end := start.Add(time.Hour) if endProp != nil { if e, err := endProp.DateTime(time.Local); err == nil { end = e.In(time.Local) } } form.EndDate = end.Format(dateLayout) form.EndTime = end.Format(timeLayout) } return form, nil }