diff --git a/caldav/caldav.go b/caldav/caldav.go new file mode 100644 index 00000000..3832ae82 --- /dev/null +++ b/caldav/caldav.go @@ -0,0 +1,378 @@ +package caldav + +import ( + "bytes" + "context" + "crypto/md5" + "errors" + "fmt" + "log" + "net/http" + "path" + "strconv" + "strings" + "sync" + "time" + + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/caldav" + "github.com/emersion/hydroxide/protonmail" +) + +var errNotFound = errors.New("caldav: not found") + +const calPathPrefix = "/calendars/" + +var defaultCalendar = &caldav.Calendar{ + Path: calPathPrefix + "default", + Name: "ProtonMail", + Description: "ProtonMail calendar", + MaxResourceSize: 100 * 1024, + SupportedComponentSet: []string{ical.CompEvent}, +} + +type backend struct { + c *protonmail.Client + calendars map[string]*protonmail.Calendar + calEvents map[string]map[string]*protonmail.CalendarEvent + locker sync.Mutex + initialized bool +} + +func (b *backend) init() error { + if b.initialized { + return nil + } + + cals, err := b.c.ListCalendars(0, 0) + if err != nil { + return fmt.Errorf("caldav: failed to list calendars: %w", err) + } + + b.locker.Lock() + b.calendars = make(map[string]*protonmail.Calendar) + b.calEvents = make(map[string]map[string]*protonmail.CalendarEvent) + for _, cal := range cals { + b.calendars[cal.ID] = cal + } + b.locker.Unlock() + + b.initialized = true + log.Printf("caldav: loaded %d calendars", len(b.calendars)) + return nil +} + +func (b *backend) ensureEventsLoaded(calID string) error { + now := time.Now() + filter := &protonmail.CalendarEventFilter{ + Start: now.AddDate(0, -6, 0).Unix(), + End: now.AddDate(0, 6, 0).Unix(), + Page: 0, + PageSize: 200, + } + + events, err := b.c.ListCalendarEvents(calID, filter) + if err != nil { + return fmt.Errorf("caldav: failed to list events: %w", err) + } + + b.locker.Lock() + if b.calEvents[calID] == nil { + b.calEvents[calID] = make(map[string]*protonmail.CalendarEvent) + } + for _, event := range events { + b.calEvents[calID][event.ID] = event + } + b.locker.Unlock() + + log.Printf("caldav: loaded %d events for calendar %s", len(events), calID) + return nil +} + +func (b *backend) CurrentUserPrincipal(ctx context.Context) (string, error) { + return "/", nil +} + +func (b *backend) CalendarHomeSetPath(ctx context.Context) (string, error) { + return calPathPrefix, nil +} + +func (b *backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { + return webdav.NewHTTPError(http.StatusForbidden, errors.New("cannot create new calendar")) +} + +func (b *backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { + if err := b.init(); err != nil { + return nil, err + } + return []caldav.Calendar{*defaultCalendar}, nil +} + +func (b *backend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) { + if p != defaultCalendar.Path { + return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found")) + } + return defaultCalendar, nil +} + +func eventToICal(event *protonmail.CalendarEvent) *ical.Calendar { + cal := ical.NewCalendar() + cal.Props.SetText(ical.PropVersion, "2.0") + cal.Props.SetText(ical.PropProductID, "-//hydroxide//ProtonMail//EN") + + vevent := ical.NewComponent(ical.CompEvent) + vevent.Props.SetText(ical.PropUID, event.ID) + + // Try to use decrypted event data + eventData := "" + for _, card := range event.PersonalEvent { + if card.Data != "" { + eventData = card.Data + break + } + } + if eventData == "" { + for _, card := range event.SharedEvents { + if card.Data != "" { + eventData = card.Data + break + } + } + } + + if eventData != "" { + if decoded, err := ical.NewDecoder(strings.NewReader(eventData)).Decode(); err == nil { + for _, comp := range decoded.Children { + cal.Children = append(cal.Children, comp) + } + return cal + } + } + + // Fallback: create minimal event with just UID + if event.CreateTime > 0 { + vevent.Props.SetText(ical.PropDateTimeStart, event.CreateTime.Time().Format("20060102T150405Z")) + } + cal.Children = append(cal.Children, vevent) + return cal +} + +func icalToRaw(cal *ical.Calendar) string { + var buf bytes.Buffer + encoder := ical.NewEncoder(&buf) + if err := encoder.Encode(cal); err != nil { + return "" + } + return buf.String() +} + +func parseObjectPath(p string) (string, string, error) { + p = path.Clean(p) + dir, filename := path.Split(p) + ext := path.Ext(filename) + if !strings.HasPrefix(dir, calPathPrefix) || ext != ".ics" { + return "", "", errNotFound + } + eventID := strings.TrimSuffix(filename, ext) + return "default", eventID, nil +} + +func formatObjectPath(eventID string) string { + return calPathPrefix + "default/" + eventID + ".ics" +} + +func (b *backend) toObject(event *protonmail.CalendarEvent, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { + icalCal := eventToICal(event) + + var buf bytes.Buffer + ical.NewEncoder(&buf).Encode(icalCal) + + modTime := event.CreateTime.Time() + if event.LastEditTime > 0 { + modTime = event.LastEditTime.Time() + } + + return &caldav.CalendarObject{ + Path: formatObjectPath(event.ID), + ModTime: modTime, + ContentLength: int64(buf.Len()), + ETag: fmt.Sprintf("%x", md5.Sum([]byte(event.ID+strconv.FormatInt(modTime.Unix(), 10)))), + Data: icalCal, + }, nil +} + +func (b *backend) getEvent(calID, eventID string) (*protonmail.CalendarEvent, bool) { + b.locker.Lock() + defer b.locker.Unlock() + if events, ok := b.calEvents[calID]; ok { + event, ok := events[eventID] + return event, ok + } + return nil, false +} + +func (b *backend) putEvent(calID string, event *protonmail.CalendarEvent) { + b.locker.Lock() + defer b.locker.Unlock() + if b.calEvents[calID] == nil { + b.calEvents[calID] = make(map[string]*protonmail.CalendarEvent) + } + b.calEvents[calID][event.ID] = event +} + +func (b *backend) delEvent(calID, eventID string) { + b.locker.Lock() + defer b.locker.Unlock() + if events, ok := b.calEvents[calID]; ok { + delete(events, eventID) + } +} + +func (b *backend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { + calID, eventID, err := parseObjectPath(p) + if err != nil { + return nil, err + } + + event, ok := b.getEvent(calID, eventID) + if !ok { + if err := b.ensureEventsLoaded(calID); err != nil { + return nil, err + } + event, ok = b.getEvent(calID, eventID) + if !ok { + return nil, errNotFound + } + } + + return b.toObject(event, req) +} + +func (b *backend) ListCalendarObjects(ctx context.Context, p string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { + if err := b.ensureEventsLoaded("default"); err != nil { + return nil, err + } + + b.locker.Lock() + events := make([]*protonmail.CalendarEvent, 0, len(b.calEvents["default"])) + for _, event := range b.calEvents["default"] { + events = append(events, event) + } + b.locker.Unlock() + + objects := make([]caldav.CalendarObject, 0, len(events)) + for _, event := range events { + obj, err := b.toObject(event, req) + if err != nil { + log.Printf("caldav: skip event %s: %v", event.ID, err) + continue + } + objects = append(objects, *obj) + } + + return objects, nil +} + +func (b *backend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { + req := caldav.CalendarCompRequest{AllProps: true, AllComps: true} + if query != nil { + req = query.CompRequest + } + + all, err := b.ListCalendarObjects(ctx, p, &req) + if err != nil { + return nil, err + } + + if query == nil { + return all, nil + } + + return caldav.Filter(query, all) +} + +func (b *backend) PutCalendarObject(ctx context.Context, p string, icalCal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { + rawData := icalToRaw(icalCal) + + var uid string + for _, comp := range icalCal.Children { + if comp.Name == ical.CompEvent || comp.Name == ical.CompToDo || comp.Name == ical.CompJournal { + uid, _ = comp.Props.Text(ical.PropUID) + break + } + } + if uid == "" { + uid = fmt.Sprintf("%d", time.Now().UnixNano()) + } + + calID := "default" + _, existingID, pathErr := parseObjectPath(p) + if pathErr == nil && existingID != "" { + if _, ok := b.getEvent(calID, existingID); ok { + // Update + event := &protonmail.CalendarEvent{ + ID: existingID, + CalendarID: calID, + LastEditTime: protonmail.Timestamp(time.Now().Unix()), + PersonalEvent: []protonmail.CalendarEventCard{ + {Data: rawData}, + }, + } + b.putEvent(calID, event) + return b.toObject(event, &caldav.CalendarCompRequest{AllProps: true, AllComps: true}) + } + } + + // Create new + event := &protonmail.CalendarEvent{ + ID: uid, + CalendarID: calID, + CreateTime: protonmail.Timestamp(time.Now().Unix()), + LastEditTime: protonmail.Timestamp(time.Now().Unix()), + PersonalEvent: []protonmail.CalendarEventCard{ + {Data: rawData}, + }, + } + b.putEvent(calID, event) + return b.toObject(event, &caldav.CalendarCompRequest{AllProps: true, AllComps: true}) +} + +func (b *backend) DeleteCalendarObject(ctx context.Context, p string) error { + calID, eventID, err := parseObjectPath(p) + if err != nil { + return err + } + + if _, ok := b.getEvent(calID, eventID); !ok { + return errNotFound + } + + b.delEvent(calID, eventID) + return nil +} + +func NewHandler(c *protonmail.Client, events <-chan *protonmail.Event) http.Handler { + b := &backend{ + c: c, + calendars: make(map[string]*protonmail.Calendar), + calEvents: make(map[string]map[string]*protonmail.CalendarEvent), + } + + // Listen for Proton event updates and invalidate the cache automatically. + // This ensures new/modified calendar events from the Proton web/app + // become visible to CalDAV clients without restarting hydroxide. + if events != nil { + go func() { + for range events { + b.locker.Lock() + b.initialized = false + b.calEvents = make(map[string]map[string]*protonmail.CalendarEvent) + b.locker.Unlock() + log.Println("caldav: cache invalidated via event notification") + } + }() + } + + return &caldav.Handler{Backend: b} +} diff --git a/cmd/hydroxide/main.go b/cmd/hydroxide/main.go index b32780fd..f1fd66d5 100644 --- a/cmd/hydroxide/main.go +++ b/cmd/hydroxide/main.go @@ -19,6 +19,7 @@ import ( "golang.org/x/term" "github.com/emersion/hydroxide/auth" + "github.com/emersion/hydroxide/caldav" "github.com/emersion/hydroxide/carddav" "github.com/emersion/hydroxide/config" "github.com/emersion/hydroxide/events" @@ -163,6 +164,55 @@ func listenAndServeCardDAV(addr string, authManager *auth.Manager, eventsManager return s.ListenAndServe() } +func listenAndServeCalDAV(addr string, authManager *auth.Manager, eventsManager *events.Manager, tlsConfig *tls.Config) error { + handlers := make(map[string]http.Handler) + + s := &http.Server{ + Addr: addr, + TLSConfig: tlsConfig, + Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + resp.Header().Set("WWW-Authenticate", "Basic") + + username, password, ok := req.BasicAuth() + if !ok { + resp.WriteHeader(http.StatusUnauthorized) + io.WriteString(resp, "Credentials are required") + return + } + + c, _, err := authManager.Auth(username, password) + if err != nil { + if err == auth.ErrUnauthorized { + resp.WriteHeader(http.StatusUnauthorized) + } else { + resp.WriteHeader(http.StatusInternalServerError) + } + io.WriteString(resp, err.Error()) + return + } + + h, ok := handlers[username] + if !ok { + ch := make(chan *protonmail.Event) + eventsManager.Register(c, username, ch, nil) + h = caldav.NewHandler(c, ch) + + handlers[username] = h + } + + h.ServeHTTP(resp, req) + }), + } + + if s.TLSConfig != nil { + log.Println("CalDAV server listening with TLS on", s.Addr) + return s.ListenAndServeTLS("", "") + } + + log.Println("CalDAV server listening on", s.Addr) + return s.ListenAndServe() +} + func isMbox(br *bufio.Reader) (bool, error) { prefix := []byte("From ") b, err := br.Peek(len(prefix)) @@ -176,6 +226,7 @@ const usage = `usage: hydroxide [options...] Commands: auth Login to ProtonMail via hydroxide carddav Run hydroxide as a CardDAV server + caldav Run hydroxide as a CalDAV server export-secret-keys Export secret keys imap Run hydroxide as an IMAP server import-messages [file] Import messages @@ -210,6 +261,12 @@ Global options: Disable SMTP for hydroxide serve -disable-carddav Disable CardDAV for hydroxide serve + -caldav-host example.com + Allowed CalDAV hostname on which hydroxide listens, defaults to 127.0.0.1 + -caldav-port example.com + CalDAV port on which hydroxide listens, defaults to 8443 + -disable-caldav + Disable CalDAV for hydroxide serve -tls-cert /path/to/cert.pem Path to the certificate to use for incoming connections (Optional) -tls-key /path/to/key.pem @@ -238,6 +295,10 @@ func main() { carddavPort := flag.String("carddav-port", "8080", "CardDAV port on which hydroxide listens, defaults to 8080") disableCardDAV := flag.Bool("disable-carddav", false, "Disable CardDAV for hydroxide serve") + caldavHost := flag.String("caldav-host", "127.0.0.1", "Allowed CalDAV hostname on which hydroxide listens, defaults to 127.0.0.1") + caldavPort := flag.String("caldav-port", "8443", "CalDAV port on which hydroxide listens, defaults to 8443") + disableCalDAV := flag.Bool("disable-caldav", false, "Disable CalDAV for hydroxide serve") + tlsCert := flag.String("tls-cert", "", "Path to the certificate to use for incoming connections") tlsCertKey := flag.String("tls-key", "", "Path to the certificate key to use for incoming connections") tlsClientCA := flag.String("tls-client-ca", "", "If set, clients must provide a certificate signed by the given CA") @@ -502,15 +563,21 @@ func main() { authManager := auth.NewManager(newClient) eventsManager := events.NewManager() log.Fatal(listenAndServeCardDAV(addr, authManager, eventsManager, tlsConfig)) + case "caldav": + addr := *caldavHost + ":" + *caldavPort + authManager := auth.NewManager(newClient) + eventsManager := events.NewManager() + log.Fatal(listenAndServeCalDAV(addr, authManager, eventsManager, tlsConfig)) case "serve": smtpAddr := *smtpHost + ":" + *smtpPort imapAddr := *imapHost + ":" + *imapPort carddavAddr := *carddavHost + ":" + *carddavPort + caldavAddr := *caldavHost + ":" + *caldavPort authManager := auth.NewManager(newClient) eventsManager := events.NewManager() - done := make(chan error, 3) + done := make(chan error, 4) if !*disableSMTP { go func() { done <- listenAndServeSMTP(smtpAddr, debug, authManager, tlsConfig) @@ -526,6 +593,11 @@ func main() { done <- listenAndServeCardDAV(carddavAddr, authManager, eventsManager, tlsConfig) }() } + if !*disableCalDAV { + go func() { + done <- listenAndServeCalDAV(caldavAddr, authManager, eventsManager, tlsConfig) + }() + } log.Fatal(<-done) case "sendmail": username := flag.Arg(1) diff --git a/go.mod b/go.mod index b3b9366e..62a1034a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/ProtonMail/go-crypto v1.4.1 github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 github.com/emersion/go-imap v1.2.1 github.com/emersion/go-mbox v1.0.4 github.com/emersion/go-message v0.18.2 @@ -19,6 +20,7 @@ require ( require ( github.com/cloudflare/circl v1.6.3 // indirect + github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 348d8c05..f52e4ce9 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= @@ -29,6 +30,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=