343 lines
11 KiB
Go
343 lines
11 KiB
Go
// CalDAV server (RFC 4791) — exposes a mailbox owner's calendar (events + reminders)
|
|
// for sync from Apple Calendar, Thunderbird, DAVx5, etc. Structurally mirrors
|
|
// carddav.go throughout (same author/family library, same ctx-based auth pattern via
|
|
// davMailboxFromCtx, same path-hierarchy depth convention). Reminders round-trip as a
|
|
// VALARM sub-component so a real CalDAV client fires its own native OS notification —
|
|
// this server has no push/email notification system of its own (see the Calendar
|
|
// plan's explicit scope note).
|
|
package webui
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-ical"
|
|
"github.com/emersion/go-webdav"
|
|
"github.com/emersion/go-webdav/caldav"
|
|
"github.com/teambition/rrule-go"
|
|
|
|
"mailgoserver/internal/db"
|
|
)
|
|
|
|
const caldavPrefix = "/caldav"
|
|
|
|
// icalProdID identifies this server as the VCALENDAR's producer, per RFC 5545 3.7.3 —
|
|
// required (checkComponent rejects an encode without it), arbitrary otherwise.
|
|
const icalProdID = "-//mailgoserver//Calendar//EN"
|
|
|
|
func caldavPrincipalPath(email string) string {
|
|
return caldavPrefix + "/" + url.PathEscape(email) + "/"
|
|
}
|
|
|
|
func caldavHomeSetPath(email string) string {
|
|
return caldavPrincipalPath(email) + "calendars/"
|
|
}
|
|
|
|
// caldavCalendarPath is the one, fixed calendar every mailbox gets — no
|
|
// multi-calendar UI exists in this app, same single-collection shape as CardDAV's one
|
|
// address book.
|
|
func caldavCalendarPath(email string) string {
|
|
return caldavHomeSetPath(email) + "default/"
|
|
}
|
|
|
|
func caldavObjectPath(email, uid string) string {
|
|
return caldavCalendarPath(email) + uid + ".ics"
|
|
}
|
|
|
|
// caldavUIDFromPath extracts the uid segment from a calendar-object request path (the
|
|
// last path segment, minus its ".ics" extension) — the inverse of caldavObjectPath.
|
|
func caldavUIDFromPath(p string) string {
|
|
return strings.TrimSuffix(path.Base(p), ".ics")
|
|
}
|
|
|
|
type caldavBackend struct {
|
|
DB *db.DB
|
|
}
|
|
|
|
func (b *caldavBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return caldavPrincipalPath(mbox.Email), nil
|
|
}
|
|
|
|
func (b *caldavBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return caldavHomeSetPath(mbox.Email), nil
|
|
}
|
|
|
|
func (b *caldavBackend) calendar(mbox *db.Mailbox) caldav.Calendar {
|
|
return caldav.Calendar{
|
|
Path: caldavCalendarPath(mbox.Email),
|
|
Name: "Calendar",
|
|
Description: "Calendar for " + mbox.Email,
|
|
SupportedComponentSet: []string{ical.CompEvent},
|
|
}
|
|
}
|
|
|
|
func (b *caldavBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []caldav.Calendar{b.calendar(mbox)}, nil
|
|
}
|
|
|
|
func (b *caldavBackend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if p != caldavCalendarPath(mbox.Email) {
|
|
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such calendar"))
|
|
}
|
|
cal := b.calendar(mbox)
|
|
return &cal, nil
|
|
}
|
|
|
|
// CreateCalendar: unsupported — every mailbox has exactly one fixed calendar, matching
|
|
// this app's own Calendar page, which has no concept of multiple calendars either (no
|
|
// DeleteCalendar exists in caldav.Backend to implement — the interface itself assumes
|
|
// a calendar, once created, persists).
|
|
func (b *caldavBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
|
|
return webdav.NewHTTPError(http.StatusForbidden, errors.New("creating calendars is not supported"))
|
|
}
|
|
|
|
// eventToICal builds the VCALENDAR a CalDAV client sees for one stored event —
|
|
// PRODID/VERSION set once, one VEVENT child with a VALARM appended when a reminder is
|
|
// set (Props.SetDuration on TRIGGER encodes "N minutes before" as a negative
|
|
// duration, the standard RFC 5545 relative-trigger form).
|
|
func eventToICal(e db.CalendarEvent) *ical.Calendar {
|
|
cal := ical.NewCalendar()
|
|
cal.Props.SetText(ical.PropProductID, icalProdID)
|
|
cal.Props.SetText(ical.PropVersion, "2.0")
|
|
|
|
event := ical.NewEvent()
|
|
event.Props.SetText(ical.PropUID, e.UID)
|
|
event.Props.SetDateTime(ical.PropDateTimeStamp, e.UpdatedAt)
|
|
event.Props.SetText(ical.PropSummary, e.Summary)
|
|
if e.Description != "" {
|
|
event.Props.SetText(ical.PropDescription, e.Description)
|
|
}
|
|
if e.Location != "" {
|
|
event.Props.SetText(ical.PropLocation, e.Location)
|
|
}
|
|
if e.AllDay {
|
|
event.Props.SetDate(ical.PropDateTimeStart, e.StartAt)
|
|
event.Props.SetDate(ical.PropDateTimeEnd, e.EndAt)
|
|
} else {
|
|
event.Props.SetDateTime(ical.PropDateTimeStart, e.StartAt)
|
|
event.Props.SetDateTime(ical.PropDateTimeEnd, e.EndAt)
|
|
}
|
|
if e.RRule != "" {
|
|
if opt, err := rrule.StrToROption(e.RRule); err == nil {
|
|
event.Props.SetRecurrenceRule(opt)
|
|
}
|
|
}
|
|
if e.Color != "" {
|
|
event.Props.SetText(ical.PropColor, e.Color)
|
|
}
|
|
if e.ReminderMinutes != nil {
|
|
alarm := ical.NewComponent(ical.CompAlarm)
|
|
alarm.Props.SetText(ical.PropAction, "DISPLAY")
|
|
alarm.Props.SetText(ical.PropDescription, "Reminder")
|
|
trigger := ical.NewProp(ical.PropTrigger)
|
|
trigger.SetDuration(-time.Duration(*e.ReminderMinutes) * time.Minute)
|
|
alarm.Props.Set(trigger)
|
|
event.Children = append(event.Children, alarm)
|
|
}
|
|
|
|
cal.Children = append(cal.Children, event.Component)
|
|
return cal
|
|
}
|
|
|
|
// icalToEvent is eventToICal's inverse — the PUT-side parse. Rejects anything
|
|
// caldav.ValidateCalendarObject wouldn't accept (multiple UIDs, a non-VEVENT
|
|
// component, etc. — the standard CalDAV single-object-resource rule) before reading
|
|
// fields off the one VEVENT.
|
|
func icalToEvent(cal *ical.Calendar) (summary, description, location string, start, end time.Time, allDay bool, rrule, color string, reminderMinutes *int, err error) {
|
|
eventType, _, err := caldav.ValidateCalendarObject(cal)
|
|
if err != nil {
|
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
|
|
}
|
|
if eventType != ical.CompEvent {
|
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, fmt.Errorf("caldav: only VEVENT is supported, got %q", eventType)
|
|
}
|
|
events := cal.Events()
|
|
if len(events) == 0 {
|
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, errors.New("caldav: no VEVENT in calendar object")
|
|
}
|
|
event := events[0]
|
|
|
|
summary, _ = event.Props.Text(ical.PropSummary)
|
|
description, _ = event.Props.Text(ical.PropDescription)
|
|
location, _ = event.Props.Text(ical.PropLocation)
|
|
color, _ = event.Props.Text(ical.PropColor)
|
|
|
|
startProp := event.Props.Get(ical.PropDateTimeStart)
|
|
if startProp == nil {
|
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, errors.New("caldav: VEVENT has no DTSTART")
|
|
}
|
|
allDay = startProp.ValueType() == ical.ValueDate
|
|
start, err = event.DateTimeStart(time.UTC)
|
|
if err != nil {
|
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
|
|
}
|
|
end, err = event.DateTimeEnd(time.UTC)
|
|
if err != nil {
|
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
|
|
}
|
|
if end.IsZero() || !end.After(start) {
|
|
end = start.Add(time.Hour)
|
|
}
|
|
|
|
if opt, roErr := event.Props.RecurrenceRule(); roErr == nil && opt != nil {
|
|
rrule = opt.RRuleString()
|
|
}
|
|
|
|
for _, child := range event.Children {
|
|
if child.Name != ical.CompAlarm {
|
|
continue
|
|
}
|
|
trigger := child.Props.Get(ical.PropTrigger)
|
|
if trigger == nil {
|
|
continue
|
|
}
|
|
if dur, durErr := trigger.Duration(); durErr == nil {
|
|
minutes := int(-dur / time.Minute)
|
|
reminderMinutes = &minutes
|
|
}
|
|
}
|
|
|
|
return summary, description, location, start, end, allDay, rrule, color, reminderMinutes, nil
|
|
}
|
|
|
|
func eventETag(e db.CalendarEvent) string {
|
|
return fmt.Sprintf("%d-%d", e.ID, e.UpdatedAt.Unix())
|
|
}
|
|
|
|
func eventToCalendarObject(email string, e db.CalendarEvent) caldav.CalendarObject {
|
|
return caldav.CalendarObject{
|
|
Path: caldavObjectPath(email, e.UID),
|
|
ModTime: e.UpdatedAt,
|
|
ETag: eventETag(e),
|
|
Data: eventToICal(e),
|
|
}
|
|
}
|
|
|
|
func (b *caldavBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e, err := b.DB.GetEventByUID(mbox.ID, caldavUIDFromPath(p))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if e == nil {
|
|
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such event"))
|
|
}
|
|
co := eventToCalendarObject(mbox.Email, *e)
|
|
return &co, nil
|
|
}
|
|
|
|
func (b *caldavBackend) ListCalendarObjects(ctx context.Context, p string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
events, err := b.DB.ListAllEvents(mbox.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]caldav.CalendarObject, len(events))
|
|
for i, e := range events {
|
|
out[i] = eventToCalendarObject(mbox.Email, e)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// QueryCalendarObjects: like carddav's QueryAddressObjects, this doesn't hand-roll
|
|
// every possible CalDAV filter — only the time-range case (by far the common one, used
|
|
// by every client's initial/incremental sync) narrows the query; anything else falls
|
|
// back to every event. A client that issued a time-range REPORT still expands
|
|
// recurring-event occurrences itself per RFC 4791, so returning the whole matching
|
|
// event resource (not per-occurrence data) is correct either way.
|
|
func (b *caldavBackend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
start, end := query.CompFilter.Start, query.CompFilter.End
|
|
var events []db.CalendarEvent
|
|
if !start.IsZero() && !end.IsZero() {
|
|
events, err = b.DB.ListEventsInRange(mbox.ID, start, end)
|
|
} else {
|
|
events, err = b.DB.ListAllEvents(mbox.ID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]caldav.CalendarObject, len(events))
|
|
for i, e := range events {
|
|
out[i] = eventToCalendarObject(mbox.Email, e)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (b *caldavBackend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summary, description, location, start, end, allDay, rrule, color, reminderMinutes, err := icalToEvent(cal)
|
|
if err != nil {
|
|
return nil, webdav.NewHTTPError(http.StatusBadRequest, err)
|
|
}
|
|
uid := caldavUIDFromPath(p)
|
|
|
|
existing, err := b.DB.GetEventByUID(mbox.ID, uid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var id int64
|
|
if existing == nil {
|
|
if id, err = b.DB.CreateEventWithUID(mbox.ID, uid, summary, description, location, start, end, allDay, rrule, color); err != nil {
|
|
return nil, webdav.NewHTTPError(http.StatusConflict, err)
|
|
}
|
|
} else {
|
|
id = existing.ID
|
|
if err := b.DB.UpdateEvent(mbox.ID, id, summary, description, location, start, end, allDay, rrule, color); err != nil {
|
|
return nil, webdav.NewHTTPError(http.StatusConflict, err)
|
|
}
|
|
}
|
|
if err := b.DB.SetEventReminder(mbox.ID, id, reminderMinutes); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stored, err := b.DB.GetEventByUID(mbox.ID, uid)
|
|
if err != nil || stored == nil {
|
|
return nil, err
|
|
}
|
|
co := eventToCalendarObject(mbox.Email, *stored)
|
|
return &co, nil
|
|
}
|
|
|
|
func (b *caldavBackend) DeleteCalendarObject(ctx context.Context, p string) error {
|
|
mbox, err := davMailboxFromCtx(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return b.DB.DeleteEventByUID(mbox.ID, caldavUIDFromPath(p))
|
|
}
|