Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions caldav/caldav.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package caldav

import (
"context"
"errors"
"fmt"
"net/http"
"path"
"strings"
"sync"
"time"

"github.com/ProtonMail/go-crypto/openpgp"
"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")

type backend struct {
c *protonmail.Client
cache map[string]*protonmail.Calendar
eventCache map[string]map[string]*protonmail.CalendarEvent // calendarID -> eventID -> event
locker sync.Mutex
privateKeys openpgp.EntityList
}

func (b *backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
return "/", nil
}

func (b *backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
return "/calendars", nil
}

func (b *backend) CreateCalendar(ctx context.Context, cal *caldav.Calendar) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("cannot create new calendar"))
}

func (b *backend) DeleteCalendar(ctx context.Context, path string) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("cannot delete calendar"))
}

func (b *backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
b.locker.Lock()
defer b.locker.Unlock()

if b.cache == nil {
calendars, err := b.c.ListCalendars(0, 0)
if err != nil {
return nil, err
}
b.cache = make(map[string]*protonmail.Calendar)
for _, cal := range calendars {
b.cache[cal.ID] = cal
}
}

var result []caldav.Calendar
for _, cal := range b.cache {
result = append(result, caldav.Calendar{
Path: "/calendars/" + cal.ID + "/",
Name: cal.Name,
Description: cal.Description,
MaxResourceSize: 10 * 1024 * 1024, // 10MB
})
}

return result, nil
}

func (b *backend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) {
calID, err := parseCalendarPath(path)
if err != nil {
return nil, err
}

calendars, err := b.ListCalendars(ctx)
if err != nil {
return nil, err
}

for _, cal := range calendars {
if strings.TrimSuffix(cal.Path, "/") == "/calendars/"+calID {
return &cal, nil
}
}

return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
}

func parseCalendarPath(p string) (string, error) {
parts := strings.Split(strings.Trim(p, "/"), "/")
if len(parts) < 2 || parts[0] != "calendars" {
return "", errNotFound
}
return parts[1], nil
}

func parseObjectPath(p string) (calendarID, eventID string, err error) {
parts := strings.Split(strings.Trim(p, "/"), "/")
if len(parts) != 3 || parts[0] != "calendars" {
return "", "", errNotFound
}
ext := path.Ext(parts[2])
if ext != ".ics" {
return "", "", errNotFound
}
return parts[1], strings.TrimSuffix(parts[2], ext), nil
}

func formatObjectPath(calendarID, eventID string) string {
return "/calendars/" + calendarID + "/" + eventID + ".ics"
}

func (b *backend) toCalendarObject(event *protonmail.CalendarEvent, cal *protonmail.Calendar) (*caldav.CalendarObject, error) {
// Create a basic iCal event
calObj := &caldav.CalendarObject{
Path: formatObjectPath(cal.ID, event.ID),
ModTime: time.Unix(event.CreateTime.Unix(), 0),
ETag: fmt.Sprintf("%x", event.LastEditTime),
}

// Parse the event data from CalendarEventCard
for _, card := range event.PersonalEvent {
if card.Data != "" {
decoded, err := ical.NewDecoder(strings.NewReader(card.Data)).Decode()
if err != nil {
continue
}
calObj.Data = decoded
break
}
}

if calObj.Data == nil {
// Create a basic calendar if no data
calObj.Data = ical.NewCalendar()
}

return calObj, nil
}

func (b *backend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
calID, eventID, err := parseObjectPath(path)
if err != nil {
return nil, err
}

// Get calendar
cal, ok := b.cache[calID]
if !ok {
calendars, err := b.c.ListCalendars(0, 0)
if err != nil {
return nil, err
}
for _, c := range calendars {
if c.ID == calID {
cal = c
break
}
}
if cal == nil {
return nil, errNotFound
}
}

// Get event
filter := &protonmail.CalendarEventFilter{
Start: time.Now().AddDate(-1, 0, 0).Unix(),
End: time.Now().AddDate(1, 0, 0).Unix(),
Timezone: "UTC",
Page: 0,
}

events, err := b.c.ListCalendarEvents(calID, filter)
if err != nil {
return nil, err
}

for _, event := range events {
if event.ID == eventID {
return b.toCalendarObject(event, cal)
}
}

return nil, errNotFound
}

func (b *backend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
calID, err := parseCalendarPath(path)
if err != nil {
return nil, err
}

// Get calendar
cal, ok := b.cache[calID]
if !ok {
calendars, err := b.c.ListCalendars(0, 0)
if err != nil {
return nil, err
}
for _, c := range calendars {
if c.ID == calID {
cal = c
break
}
}
if cal == nil {
return nil, errNotFound
}
}

// Get events
filter := &protonmail.CalendarEventFilter{
Start: time.Now().AddDate(-1, 0, 0).Unix(),
End: time.Now().AddDate(1, 0, 0).Unix(),
Timezone: "UTC",
Page: 0,
}

events, err := b.c.ListCalendarEvents(calID, filter)
if err != nil {
return nil, err
}

var result []caldav.CalendarObject
for _, event := range events {
obj, err := b.toCalendarObject(event, cal)
if err != nil {
continue
}
result = append(result, *obj)
}

return result, nil
}

func (b *backend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
req := caldav.CalendarCompRequest{AllProps: true}
if query != nil {
req = query.CompRequest
}
return b.ListCalendarObjects(ctx, path, &req)
}

func (b *backend) PutCalendarObject(ctx context.Context, path string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
// TODO: Implement creating/updating calendar events
return nil, webdav.NewHTTPError(http.StatusNotImplemented, errors.New("calendar event creation not yet implemented"))
}

func (b *backend) DeleteCalendarObject(ctx context.Context, path string) error {
// TODO: Implement deleting calendar events
return webdav.NewHTTPError(http.StatusNotImplemented, errors.New("calendar event deletion not yet implemented"))
}

func NewHandler(c *protonmail.Client, privateKeys openpgp.EntityList, events <-chan *protonmail.Event) http.Handler {
if len(privateKeys) == 0 {
panic("hydroxide/caldav: no private key available")
}

b := &backend{
c: c,
cache: make(map[string]*protonmail.Calendar),
eventCache: make(map[string]map[string]*protonmail.CalendarEvent),
privateKeys: privateKeys,
}

return &caldav.Handler{Backend: b}
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
module github.com/emersion/hydroxide

go 1.24.0
go 1.22

require (
github.com/ProtonMail/go-crypto v1.3.0
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
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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:F6kGPGTlORycwgD7gsKlB81RJ4e0Ey//t8PQ5/vQjlE=
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=
Expand Down