Files

1410 lines
45 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
"git.arnef.de/arnef/nidus/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web/templates"
ical "github.com/emersion/go-ical"
)
// 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 "<id>.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 "<owner>~<name>" 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 "!<name>" 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 expanded: every occurrence falling in the grid's window is placed
// (see eventOccurrenceDays), not just the event's base DTSTART/DTEND.
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
}
ev, err := firstEventFromICS(data)
if err != nil {
s.logger.Warn("decoding calendar object", "calendar", entry.Name, "id", id, "error", err)
continue
}
daysSet := eventOccurrenceDays(ev, gridStart, gridEnd, loc, dayIndex)
if len(daysSet) == 0 {
continue
}
form, err := eventFormFromComponent(id, ev)
if err != nil {
continue
}
timeText := ""
if !form.AllDay {
timeText = form.StartTime
}
for idx := range daysSet {
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)
}
// eventOccurrenceDays returns the set of grid day indices (into
// dayIndex) that event ev occupies within [gridStart, gridEnd]. A plain
// event occupies its DTSTART..DTEND day span. A recurring event (RRULE)
// occupies the day span of each occurrence whose expansion falls in the
// window, so e.g. a weekly meeting is painted on every in-grid weekly date
// rather than only its original date. EXDATE/RDATE are honored via
// go-ical's RecurrenceSet. Only indices for days actually in the grid are
// returned; occurrences entirely outside the window paint nothing.
func eventOccurrenceDays(ev ical.Event, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int) map[int]bool {
out := make(map[int]bool)
addDay := func(day time.Time) {
day = day.In(loc)
if idx, ok := dayIndex[day.Format(dateLayout)]; ok {
out[idx] = true
}
}
startProp := ev.Props.Get(ical.PropDateTimeStart)
if startProp == nil {
return nil
}
baseStart, err := startProp.DateTime(loc)
if err != nil {
return nil
}
// Per-occurrence duration: the DTSTART..DTEND span of the base event
// (all-day events with only DTEND=DTSTART+1 give a 1-day span; timed
// events give their hour span). Recurrence preserves the duration.
dur := time.Duration(0)
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
if baseEnd, err := endProp.DateTime(loc); err == nil {
dur = baseEnd.Sub(baseStart)
}
}
spread := func(start time.Time) {
end := start.Add(dur)
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
addDay(d)
if len(out) == len(dayIndex) {
return
}
}
}
if rset, rerr := ev.RecurrenceSet(loc); rerr == nil && rset != nil {
for _, occ := range rset.Between(gridStart, gridEnd, true) {
spread(occ)
}
} else {
spread(baseStart)
}
return out
}
// firstEventFromICS decodes data (a single-VEVENT calendar object) and
// returns its first VEVENT, for grid placement (which needs the raw
// ical.Event so recurring events can be expanded, see
// eventOccurrenceDays).
func firstEventFromICS(data []byte) (ical.Event, error) {
calendar, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
if err != nil {
return ical.Event{}, err
}
events := calendar.Events()
if len(events) == 0 {
return ical.Event{}, fmt.Errorf("no VEVENT in calendar object")
}
return events[0], 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),
}
}
// handleEventView renders the read-only detail view for a single event. It
// is the landing page when a user clicks an event in the month/week grid;
// writable calendars link from here to the edit page.
func (s *Server) handleEventView(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())
ref := r.PathValue("ref")
id := r.PathValue("id")
// For ICS subscriptions, ref is "!<name>" and id is the stable
// icssub.EventID (already ".ics"-suffixed). For regular calendars,
// ref is a bare name or "owner~name" and id is a "<hex>.ics" filename
// from the eventIDRe character set. Both shapes share the same
// "hex/alnum .ics" shape, so we only need the regex check.
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
color, form, err := s.eventForDisplay(username, ref, id)
if errors.Is(err, store.ErrNotFound) || errors.Is(err, errCalendarNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.handleCalRefError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventDetail(username, templates.EventDetailData{Form: form, Color: color}).Render(context.Background(), w)
}
// eventForDisplay resolves a calendar reference for read access, loads and
// decodes the event with the given id, populates the form's display fields
// (CalRef, CalendarLabel, Writable), and returns the calendar's color for
// the detail view's header dot. It handles both ordinary (stored) events
// and ICS-subscription events (ref "!<name>").
func (s *Server) eventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
if strings.HasPrefix(ref, icsRefPrefix) {
return s.icsEventForDisplay(username, ref, id)
}
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
return "", form, err
}
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
return "", form, err
}
form, err = eventFormFromICS(id, data)
if err != nil {
s.logger.Error("decoding event", "error", err)
return "", form, err
}
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
}
}
color, _ = s.dbase.GetCalendarColor(owner, name)
return color, form, nil
}
// icsEventForDisplay resolves one of username's ICS subscriptions named
// ref[len(!):] and looks up the Series whose icssub ID matches id, then
// returns the subscription's display color and a form (Writable=false)
// summarizing the series' anchor event (base, or earliest instance).
func (s *Server) icsEventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
name := strings.TrimPrefix(ref, icsRefPrefix)
sub, err := s.dbase.GetICSSubscription(username, name)
if err != nil {
return "", form, errCalendarNotFound
}
cal, err := s.icsCache.Get(sub.URL)
if err != nil {
return "", form, err
}
for _, series := range icssub.GroupSeries(cal) {
if series.Base == nil && len(series.Instances) == 0 {
continue
}
if series.ID() != id {
continue
}
anchor := series.Anchor()
form, err = eventFormFromComponent(id, *anchor)
if err != nil {
return "", form, err
}
form.CalRef = ref
form.CalendarLabel = name
form.Writable = false
return sub.Color, form, nil
}
return "", form, errCalendarNotFound
}
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 <select>
// on a validation error).
func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, owner, name, ref, id string, options []templates.CalendarOption) {
username := userFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
in := parseEventForm(r)
reRender := func(errMsg string) {
form := templates.EventFormData{
CalRef: ref, 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,
Calendars: options,
}
if id != "" {
label := name
if owner != username {
label = name + " (" + owner + ")"
}
form.CalendarLabel = label
}
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(owner, "cal-"+name, id)
if err != nil {
http.NotFound(w, r)
return
}
existing, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(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(owner, name, 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", 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(owner, 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(owner, "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(bytes.NewReader(icalfix.NormalizeTimeZones(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)
}
return eventFormFromComponent(id, events[0])
}
// eventFormFromComponent extracts an EventFormData from a single decoded
// VEVENT, shared by eventFormFromICS (one event per stored .ics object)
// and the ICS-subscription rendering path (many events per fetched
// calendar, see addICSEvents).
func eventFormFromComponent(id string, ev ical.Event) (templates.EventFormData, error) {
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
}
// handleCalendarExportOne exports a single event as a standalone .ics
// file.
func (s *Server) handleEventExportOne(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())
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
}
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+id+`"`)
_, _ = w.Write(data)
}
// handleCalendarExportAll exports every event in a calendar as one
// concatenated .ics file.
func (s *Server) handleCalendarExportAll(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())
ref := r.PathValue("ref")
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
ids, err := s.store.ListObjects(owner, "cal-"+name)
if err != nil {
s.logger.Error("listing calendar objects", "calendar", name, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`.ics"`)
for _, id := range ids {
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
continue
}
// Each stored object is already a complete, self-contained
// VCALENDAR document, so concatenating their raw bytes produces a
// file most clients will happily re-import (though it is not a
// single strictly-valid multi-event VCALENDAR document).
_, _ = w.Write(data)
}
}
// handleCalendarImport accepts an uploaded .ics file that may contain one
// or more VCALENDAR blocks, each possibly holding multiple VEVENTs, and
// stores each VEVENT as its own object (mirroring how the CalDAV backend
// stores one event per object).
func (s *Server) handleCalendarImport(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")
owner, name, err := s.resolveCalRef(username, ref, true)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
http.Error(w, "invalid upload", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file provided", http.StatusBadRequest)
return
}
defer file.Close()
body, err := io.ReadAll(file)
if err != nil {
s.logger.Error("reading import file", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
imported := 0
dec := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(body)))
for {
srcCal, err := dec.Decode()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
s.logger.Warn("stopping ics import on parse error", "calendar", name, "error", err)
break
}
version := "2.0"
if p := srcCal.Props.Get(ical.PropVersion); p != nil && p.Value != "" {
version = p.Value
}
prodID := "-//nidus//web calendar//EN"
if p := srcCal.Props.Get(ical.PropProductID); p != nil && p.Value != "" {
prodID = p.Value
}
var timezones []*ical.Component
for _, child := range srcCal.Children {
if child.Name == ical.CompTimezone {
timezones = append(timezones, child)
}
}
for _, ev := range srcCal.Events() {
id, err := newEventID()
if err != nil {
s.logger.Error("generating event id", "error", err)
continue
}
out := ical.NewCalendar()
out.Props.SetText(ical.PropVersion, version)
out.Props.SetText(ical.PropProductID, prodID)
out.Children = append(out.Children, timezones...)
out.Children = append(out.Children, ev.Component)
if err := s.saveEvent(owner, name, id, out); err != nil {
s.logger.Warn("saving imported event", "error", err)
continue
}
imported++
}
}
http.Redirect(w, r, "/web/calendar?imported="+strconv.Itoa(imported), http.StatusSeeOther)
}
// addBirthdayEvents places one virtual all-day event per contact
// birthday falling within [gridStart, gridEnd] into days, titled
// "🎂 Name" (or "🎂 Name (Age)" when the birth year is known). Each event
// links to the contact's edit page instead of an event edit page, since
// there is no underlying calendar object to edit.
func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridStart, gridEnd time.Time, dayIndex map[string]int, days []templates.MonthDay) error {
contacts, err := birthdays.Collect(s.store, s.dbase, username)
if err != nil {
return err
}
if len(contacts) == 0 {
return nil
}
// The 42-day grid can span at most two calendar years (e.g. a
// December/January boundary), so only those years' occurrences need
// checking.
years := []int{gridStart.Year()}
if gridEnd.Year() != gridStart.Year() {
years = append(years, gridEnd.Year())
}
for _, c := range contacts {
for _, year := range years {
occurrence, ok := birthdays.OccurrenceDate(c, year, gridStart.Location())
if !ok {
continue
}
if occurrence.Before(gridStart) || occurrence.After(gridEnd) {
continue
}
idx, ok := dayIndex[occurrence.Format(dateLayout)]
if !ok {
continue
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: c.Book + "/" + c.ID,
CalRef: entry.Ref,
Color: entry.Color,
Summary: birthdays.Summary(c, year),
AllDay: true,
LinkURL: "/web/contacts/" + c.Book + "/" + c.ID + "/edit",
})
}
}
return nil
}
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache,
// which uses stale-while-revalidate so a month-view render never blocks on
// network I/O) and places each Series' day-occurrences onto the grid.
//
// A "Series" is one UID group in the feed: the base (RRULE-carrying)
// VEVENT plus any explicit per-occurrence instances. OccurrencesIn
// collapses them to one event per (series, day), so a day the base RRULE
// covers AND an explicit instance covers is painted once, with the
// instance winning (Exchange's "override" model — this is how "Canceled:"
// entries replace the base occurrence for that day).
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error {
cal, err := s.icsCache.Get(entry.ICSURL)
if err != nil {
return err
}
for _, series := range icssub.GroupSeries(cal) {
if series.Base == nil && len(series.Instances) == 0 {
continue
}
id := series.ID()
occByDay := series.OccurrencesIn(gridStart, gridEnd, loc)
if len(occByDay) == 0 {
continue
}
anchor := series.Anchor()
baseForm, err := eventFormFromComponent(id, *anchor)
if err != nil {
continue
}
for dayStr, ev := range occByDay {
idx, ok := dayIndex[dayStr]
if !ok {
continue
}
// Per-day data: the day's own summary (an explicit instance
// may override the base's — "Canceled: X" or a per-instance
// title change). Fall back to the base summary otherwise.
form := baseForm
if ps := ev.Props.Get(ical.PropSummary); ps != nil && ps.Value != "" {
form.Summary = ps.Value
}
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,
})
}
}
return nil
}