1+ package caldav
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "crypto/md5"
7+ "errors"
8+ "fmt"
9+ "log"
10+ "net/http"
11+ "path"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "time"
16+
17+ "github.com/emersion/go-ical"
18+ "github.com/emersion/go-webdav"
19+ "github.com/emersion/go-webdav/caldav"
20+ "github.com/emersion/hydroxide/protonmail"
21+ )
22+
23+ var errNotFound = errors .New ("caldav: not found" )
24+
25+ const calPathPrefix = "/calendars/"
26+
27+ var defaultCalendar = & caldav.Calendar {
28+ Path : calPathPrefix + "default" ,
29+ Name : "ProtonMail" ,
30+ Description : "ProtonMail calendar" ,
31+ MaxResourceSize : 100 * 1024 ,
32+ SupportedComponentSet : []string {ical .CompEvent },
33+ }
34+
35+ type backend struct {
36+ c * protonmail.Client
37+ calendars map [string ]* protonmail.Calendar
38+ calEvents map [string ]map [string ]* protonmail.CalendarEvent
39+ locker sync.Mutex
40+ initialized bool
41+ }
42+
43+ func (b * backend ) init () error {
44+ if b .initialized {
45+ return nil
46+ }
47+
48+ cals , err := b .c .ListCalendars (0 , 0 )
49+ if err != nil {
50+ return fmt .Errorf ("caldav: failed to list calendars: %w" , err )
51+ }
52+
53+ b .locker .Lock ()
54+ b .calendars = make (map [string ]* protonmail.Calendar )
55+ b .calEvents = make (map [string ]map [string ]* protonmail.CalendarEvent )
56+ for _ , cal := range cals {
57+ b .calendars [cal .ID ] = cal
58+ }
59+ b .locker .Unlock ()
60+
61+ b .initialized = true
62+ log .Printf ("caldav: loaded %d calendars" , len (b .calendars ))
63+ return nil
64+ }
65+
66+ func (b * backend ) ensureEventsLoaded (calID string ) error {
67+ now := time .Now ()
68+ filter := & protonmail.CalendarEventFilter {
69+ Start : now .AddDate (0 , - 6 , 0 ).Unix (),
70+ End : now .AddDate (0 , 6 , 0 ).Unix (),
71+ Page : 0 ,
72+ PageSize : 200 ,
73+ }
74+
75+ events , err := b .c .ListCalendarEvents (calID , filter )
76+ if err != nil {
77+ return fmt .Errorf ("caldav: failed to list events: %w" , err )
78+ }
79+
80+ b .locker .Lock ()
81+ if b .calEvents [calID ] == nil {
82+ b .calEvents [calID ] = make (map [string ]* protonmail.CalendarEvent )
83+ }
84+ for _ , event := range events {
85+ b.calEvents [calID ][event.ID ] = event
86+ }
87+ b .locker .Unlock ()
88+
89+ log .Printf ("caldav: loaded %d events for calendar %s" , len (events ), calID )
90+ return nil
91+ }
92+
93+ func (b * backend ) CurrentUserPrincipal (ctx context.Context ) (string , error ) {
94+ return "/" , nil
95+ }
96+
97+ func (b * backend ) CalendarHomeSetPath (ctx context.Context ) (string , error ) {
98+ return calPathPrefix , nil
99+ }
100+
101+ func (b * backend ) CreateCalendar (ctx context.Context , calendar * caldav.Calendar ) error {
102+ return webdav .NewHTTPError (http .StatusForbidden , errors .New ("cannot create new calendar" ))
103+ }
104+
105+ func (b * backend ) ListCalendars (ctx context.Context ) ([]caldav.Calendar , error ) {
106+ if err := b .init (); err != nil {
107+ return nil , err
108+ }
109+ return []caldav.Calendar {* defaultCalendar }, nil
110+ }
111+
112+ func (b * backend ) GetCalendar (ctx context.Context , p string ) (* caldav.Calendar , error ) {
113+ if p != defaultCalendar .Path {
114+ return nil , webdav .NewHTTPError (http .StatusNotFound , errors .New ("calendar not found" ))
115+ }
116+ return defaultCalendar , nil
117+ }
118+
119+ func eventToICal (event * protonmail.CalendarEvent ) * ical.Calendar {
120+ cal := ical .NewCalendar ()
121+ cal .Props .SetText (ical .PropVersion , "2.0" )
122+ cal .Props .SetText (ical .PropProductID , "-//hydroxide//ProtonMail//EN" )
123+
124+ vevent := ical .NewComponent (ical .CompEvent )
125+ vevent .Props .SetText (ical .PropUID , event .ID )
126+
127+ // Try to use decrypted event data
128+ eventData := ""
129+ for _ , card := range event .PersonalEvent {
130+ if card .Data != "" {
131+ eventData = card .Data
132+ break
133+ }
134+ }
135+ if eventData == "" {
136+ for _ , card := range event .SharedEvents {
137+ if card .Data != "" {
138+ eventData = card .Data
139+ break
140+ }
141+ }
142+ }
143+
144+ if eventData != "" {
145+ if decoded , err := ical .NewDecoder (strings .NewReader (eventData )).Decode (); err == nil {
146+ for _ , comp := range decoded .Children {
147+ cal .Children = append (cal .Children , comp )
148+ }
149+ return cal
150+ }
151+ }
152+
153+ // Fallback: create minimal event with just UID
154+ if event .CreateTime > 0 {
155+ vevent .Props .SetText (ical .PropDateTimeStart , event .CreateTime .Time ().Format ("20060102T150405Z" ))
156+ }
157+ cal .Children = append (cal .Children , vevent )
158+ return cal
159+ }
160+
161+ func icalToRaw (cal * ical.Calendar ) string {
162+ var buf bytes.Buffer
163+ encoder := ical .NewEncoder (& buf )
164+ if err := encoder .Encode (cal ); err != nil {
165+ return ""
166+ }
167+ return buf .String ()
168+ }
169+
170+ func parseObjectPath (p string ) (string , string , error ) {
171+ p = path .Clean (p )
172+ dir , filename := path .Split (p )
173+ ext := path .Ext (filename )
174+ if ! strings .HasPrefix (dir , calPathPrefix ) || ext != ".ics" {
175+ return "" , "" , errNotFound
176+ }
177+ eventID := strings .TrimSuffix (filename , ext )
178+ return "default" , eventID , nil
179+ }
180+
181+ func formatObjectPath (eventID string ) string {
182+ return calPathPrefix + "default/" + eventID + ".ics"
183+ }
184+
185+ func (b * backend ) toObject (event * protonmail.CalendarEvent , req * caldav.CalendarCompRequest ) (* caldav.CalendarObject , error ) {
186+ icalCal := eventToICal (event )
187+
188+ var buf bytes.Buffer
189+ ical .NewEncoder (& buf ).Encode (icalCal )
190+
191+ modTime := event .CreateTime .Time ()
192+ if event .LastEditTime > 0 {
193+ modTime = event .LastEditTime .Time ()
194+ }
195+
196+ return & caldav.CalendarObject {
197+ Path : formatObjectPath (event .ID ),
198+ ModTime : modTime ,
199+ ContentLength : int64 (buf .Len ()),
200+ ETag : fmt .Sprintf ("%x" , md5 .Sum ([]byte (event .ID + strconv .FormatInt (modTime .Unix (), 10 )))),
201+ Data : icalCal ,
202+ }, nil
203+ }
204+
205+ func (b * backend ) getEvent (calID , eventID string ) (* protonmail.CalendarEvent , bool ) {
206+ b .locker .Lock ()
207+ defer b .locker .Unlock ()
208+ if events , ok := b .calEvents [calID ]; ok {
209+ event , ok := events [eventID ]
210+ return event , ok
211+ }
212+ return nil , false
213+ }
214+
215+ func (b * backend ) putEvent (calID string , event * protonmail.CalendarEvent ) {
216+ b .locker .Lock ()
217+ defer b .locker .Unlock ()
218+ if b .calEvents [calID ] == nil {
219+ b .calEvents [calID ] = make (map [string ]* protonmail.CalendarEvent )
220+ }
221+ b.calEvents [calID ][event.ID ] = event
222+ }
223+
224+ func (b * backend ) delEvent (calID , eventID string ) {
225+ b .locker .Lock ()
226+ defer b .locker .Unlock ()
227+ if events , ok := b .calEvents [calID ]; ok {
228+ delete (events , eventID )
229+ }
230+ }
231+
232+ func (b * backend ) GetCalendarObject (ctx context.Context , p string , req * caldav.CalendarCompRequest ) (* caldav.CalendarObject , error ) {
233+ calID , eventID , err := parseObjectPath (p )
234+ if err != nil {
235+ return nil , err
236+ }
237+
238+ event , ok := b .getEvent (calID , eventID )
239+ if ! ok {
240+ if err := b .ensureEventsLoaded (calID ); err != nil {
241+ return nil , err
242+ }
243+ event , ok = b .getEvent (calID , eventID )
244+ if ! ok {
245+ return nil , errNotFound
246+ }
247+ }
248+
249+ return b .toObject (event , req )
250+ }
251+
252+ func (b * backend ) ListCalendarObjects (ctx context.Context , p string , req * caldav.CalendarCompRequest ) ([]caldav.CalendarObject , error ) {
253+ if err := b .ensureEventsLoaded ("default" ); err != nil {
254+ return nil , err
255+ }
256+
257+ b .locker .Lock ()
258+ events := make ([]* protonmail.CalendarEvent , 0 , len (b .calEvents ["default" ]))
259+ for _ , event := range b .calEvents ["default" ] {
260+ events = append (events , event )
261+ }
262+ b .locker .Unlock ()
263+
264+ objects := make ([]caldav.CalendarObject , 0 , len (events ))
265+ for _ , event := range events {
266+ obj , err := b .toObject (event , req )
267+ if err != nil {
268+ log .Printf ("caldav: skip event %s: %v" , event .ID , err )
269+ continue
270+ }
271+ objects = append (objects , * obj )
272+ }
273+
274+ return objects , nil
275+ }
276+
277+ func (b * backend ) QueryCalendarObjects (ctx context.Context , p string , query * caldav.CalendarQuery ) ([]caldav.CalendarObject , error ) {
278+ req := caldav.CalendarCompRequest {AllProps : true , AllComps : true }
279+ if query != nil {
280+ req = query .CompRequest
281+ }
282+
283+ all , err := b .ListCalendarObjects (ctx , p , & req )
284+ if err != nil {
285+ return nil , err
286+ }
287+
288+ if query == nil {
289+ return all , nil
290+ }
291+
292+ return caldav .Filter (query , all )
293+ }
294+
295+ func (b * backend ) PutCalendarObject (ctx context.Context , p string , icalCal * ical.Calendar , opts * caldav.PutCalendarObjectOptions ) (* caldav.CalendarObject , error ) {
296+ rawData := icalToRaw (icalCal )
297+
298+ var uid string
299+ for _ , comp := range icalCal .Children {
300+ if comp .Name == ical .CompEvent || comp .Name == ical .CompToDo || comp .Name == ical .CompJournal {
301+ uid , _ = comp .Props .Text (ical .PropUID )
302+ break
303+ }
304+ }
305+ if uid == "" {
306+ uid = fmt .Sprintf ("%d" , time .Now ().UnixNano ())
307+ }
308+
309+ calID := "default"
310+ _ , existingID , pathErr := parseObjectPath (p )
311+ if pathErr == nil && existingID != "" {
312+ if _ , ok := b .getEvent (calID , existingID ); ok {
313+ // Update
314+ event := & protonmail.CalendarEvent {
315+ ID : existingID ,
316+ CalendarID : calID ,
317+ LastEditTime : protonmail .Timestamp (time .Now ().Unix ()),
318+ PersonalEvent : []protonmail.CalendarEventCard {
319+ {Data : rawData },
320+ },
321+ }
322+ b .putEvent (calID , event )
323+ return b .toObject (event , & caldav.CalendarCompRequest {AllProps : true , AllComps : true })
324+ }
325+ }
326+
327+ // Create new
328+ event := & protonmail.CalendarEvent {
329+ ID : uid ,
330+ CalendarID : calID ,
331+ CreateTime : protonmail .Timestamp (time .Now ().Unix ()),
332+ LastEditTime : protonmail .Timestamp (time .Now ().Unix ()),
333+ PersonalEvent : []protonmail.CalendarEventCard {
334+ {Data : rawData },
335+ },
336+ }
337+ b .putEvent (calID , event )
338+ return b .toObject (event , & caldav.CalendarCompRequest {AllProps : true , AllComps : true })
339+ }
340+
341+ func (b * backend ) DeleteCalendarObject (ctx context.Context , p string ) error {
342+ calID , eventID , err := parseObjectPath (p )
343+ if err != nil {
344+ return err
345+ }
346+
347+ if _ , ok := b .getEvent (calID , eventID ); ! ok {
348+ return errNotFound
349+ }
350+
351+ b .delEvent (calID , eventID )
352+ return nil
353+ }
354+
355+ func NewHandler (c * protonmail.Client , events <- chan * protonmail.Event ) http.Handler {
356+ b := & backend {
357+ c : c ,
358+ calendars : make (map [string ]* protonmail.Calendar ),
359+ calEvents : make (map [string ]map [string ]* protonmail.CalendarEvent ),
360+ }
361+
362+ return & caldav.Handler {Backend : b }
363+ }
0 commit comments