// Package caldav implements a CalDAV server (RFC 4791 over WebDAV RFC 4918). // Authentication: HTTP Basic Auth against the user DB. // Events are stored as AES-256-GCM encrypted iCalendar blobs. package caldav import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/xml" "fmt" "io" "log" "net/http" "strconv" "strings" "time" appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" ) const ( nsDAV = "DAV:" nsCalDAV = "urn:ietf:params:xml:ns:caldav" nsCS = "http://calendarserver.org/ns/" ) // Deps holds CalDAV server dependencies. type Deps struct { DB *db.DB Crypt *appCrypto.Crypto } // Server is the CalDAV HTTP handler. type Server struct { deps *Deps mux *http.ServeMux } // New creates a CalDAV server and registers handlers on the given mux prefix. func New(deps *Deps) *Server { s := &Server{deps: deps, mux: http.NewServeMux()} s.setup() return s } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } func (s *Server) setup() { // Well-known discovery redirects. s.mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/caldav/", http.StatusMovedPermanently) }) // All CalDAV routes use a catch-all; we route internally by path/method. s.mux.HandleFunc("/caldav/", s.withAuth(s.route)) } // withAuth wraps a handler requiring HTTP Basic Auth. func (s *Server) withAuth(next func(http.ResponseWriter, *http.Request, *models.User)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, err := s.authenticate(r) if err != nil || user == nil { w.Header().Set("WWW-Authenticate", `Basic realm="mailgosend CalDAV", charset="UTF-8"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if !user.Enabled { http.Error(w, "Account disabled", http.StatusForbidden) return } next(w, r, user) } } func (s *Server) authenticate(r *http.Request) (*models.User, error) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Basic ") { return nil, nil } decoded, err := base64.StdEncoding.DecodeString(authHeader[6:]) if err != nil { return nil, fmt.Errorf("basic auth decode: %w", err) } parts := strings.SplitN(string(decoded), ":", 2) if len(parts) != 2 { return nil, fmt.Errorf("basic auth format") } email := strings.TrimSpace(parts[0]) password := parts[1] ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() user, err := s.deps.DB.GetUserByEmail(ctx, email) if err != nil || user == nil { return nil, err } if err := appCrypto.CheckPassword(user.PasswordHash, password); err != nil { return nil, nil } return user, nil } // route dispatches CalDAV requests by path structure and HTTP method. // Path patterns under /caldav/: // /caldav/ → root (discovery) // /caldav/p/{userID} → principal // /caldav/{userID}/ → calendar home // /caldav/{userID}/{calID}/ → calendar collection // /caldav/{userID}/{calID}/{uid}.ics → event resource func (s *Server) route(w http.ResponseWriter, r *http.Request, user *models.User) { path := strings.TrimPrefix(r.URL.Path, "/caldav") path = strings.TrimSuffix(path, "/") segments := splitPath(path) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() r = r.WithContext(ctx) switch len(segments) { case 0: // /caldav/ — root s.handleOptions(w, r, "caldav") if r.Method == "PROPFIND" { s.propfindRoot(w, r, user) } case 1: if segments[0] == "p" || strings.HasPrefix(segments[0], "p") { // /caldav/p — principal without user ID (redirect to user principal) http.Redirect(w, r, fmt.Sprintf("/caldav/p/%d", user.ID), http.StatusMovedPermanently) return } // /caldav/{userID}/ — calendar home ownerID, err := strconv.ParseInt(segments[0], 10, 64) if err != nil || ownerID != user.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } switch r.Method { case "OPTIONS": s.handleOptions(w, r, "collection") case "PROPFIND": s.propfindHome(w, r, user) case "MKCOL": s.mkcalendarHome(w, r, user) default: w.Header().Set("Allow", davAllow) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case 2: // /caldav/p/{userID} — principal if segments[0] == "p" { switch r.Method { case "OPTIONS": s.handleOptions(w, r, "principal") case "PROPFIND": s.propfindPrincipal(w, r, user) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } return } // /caldav/{userID}/{calID}/ — calendar collection ownerID, _ := strconv.ParseInt(segments[0], 10, 64) if ownerID != user.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } calID, err := strconv.ParseInt(segments[1], 10, 64) if err != nil { http.NotFound(w, r) return } cal, err := s.deps.DB.GetCalendarByID(r.Context(), calID) if err != nil || cal == nil || cal.UserID != user.ID { http.NotFound(w, r) return } switch r.Method { case "OPTIONS": s.handleOptions(w, r, "calendar") case "PROPFIND": s.propfindCalendar(w, r, user, cal) case "REPORT": s.reportCalendar(w, r, user, cal) case "DELETE": s.deps.DB.DeleteCalendar(r.Context(), calID) //nolint:errcheck w.WriteHeader(http.StatusNoContent) default: w.Header().Set("Allow", davAllow) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case 3: // /caldav/{userID}/{calID}/{uid}.ics — event resource ownerID, _ := strconv.ParseInt(segments[0], 10, 64) if ownerID != user.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } calID, err := strconv.ParseInt(segments[1], 10, 64) if err != nil { http.NotFound(w, r) return } cal, err := s.deps.DB.GetCalendarByID(r.Context(), calID) if err != nil || cal == nil || cal.UserID != user.ID { http.NotFound(w, r) return } uid := strings.TrimSuffix(segments[2], ".ics") switch r.Method { case "GET", "HEAD": s.getEvent(w, r, user, cal, uid) case "PUT": s.putEvent(w, r, user, cal, uid) case "DELETE": s.deleteEvent(w, r, user, cal, uid) case "OPTIONS": s.handleOptions(w, r, "event") default: w.Header().Set("Allow", davAllow) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } default: http.NotFound(w, r) } } const davAllow = "OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL, REPORT" func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request, resType string) { w.Header().Set("DAV", "1, 2, 3, calendar-access") w.Header().Set("Allow", davAllow) w.Header().Set("Ms-Author-Via", "DAV") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) } } // ---- PROPFIND handlers ---- func (s *Server) propfindRoot(w http.ResponseWriter, r *http.Request, user *models.User) { depth := r.Header.Get("Depth") if depth == "" { depth = "0" } responses := []davResponse{ { Href: "/caldav/", Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ""}, {Name: "displayname", NS: nsDAV, Value: "CalDAV"}, {Name: "current-user-principal", NS: nsDAV, Value: fmt.Sprintf("/caldav/p/%d", user.ID)}, }, }, } writeMultiStatus(w, responses) } func (s *Server) propfindPrincipal(w http.ResponseWriter, r *http.Request, user *models.User) { responses := []davResponse{ { Href: fmt.Sprintf("/caldav/p/%d", user.ID), Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ""}, {Name: "displayname", NS: nsDAV, Value: xmlEscape(user.DisplayName)}, {Name: "current-user-principal", NS: nsDAV, Value: fmt.Sprintf("/caldav/p/%d", user.ID)}, {Name: "calendar-home-set", NS: nsCalDAV, Value: fmt.Sprintf("/caldav/%d/", user.ID)}, }, }, } writeMultiStatus(w, responses) } func (s *Server) propfindHome(w http.ResponseWriter, r *http.Request, user *models.User) { depth := r.Header.Get("Depth") cals, err := s.deps.DB.ListCalendars(r.Context(), user.ID) if err != nil { log.Printf("[caldav] list calendars: %v", err) } responses := []davResponse{ { Href: fmt.Sprintf("/caldav/%d/", user.ID), Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ""}, {Name: "displayname", NS: nsDAV, Value: "Calendars"}, {Name: "current-user-principal", NS: nsDAV, Value: fmt.Sprintf("/caldav/p/%d", user.ID)}, {Name: "calendar-home-set", NS: nsCalDAV, Value: fmt.Sprintf("/caldav/%d/", user.ID)}, }, }, } if depth != "0" { for _, cal := range cals { responses = append(responses, calendarResponse(user.ID, cal)) } } writeMultiStatus(w, responses) } func (s *Server) propfindCalendar(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar) { depth := r.Header.Get("Depth") responses := []davResponse{calendarResponse(user.ID, cal)} if depth != "0" { events, err := s.deps.DB.ListCalendarEvents(r.Context(), cal.ID) if err != nil { log.Printf("[caldav] list events: %v", err) } for _, ev := range events { responses = append(responses, eventResponse(user.ID, cal.ID, ev)) } } writeMultiStatus(w, responses) } func calendarResponse(userID int64, cal *models.Calendar) davResponse { ctag := strconv.FormatInt(cal.SyncToken, 10) syncToken := fmt.Sprintf("https://example.com/ns/sync/%d", cal.SyncToken) return davResponse{ Href: fmt.Sprintf("/caldav/%d/%d/", userID, cal.ID), Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ``}, {Name: "displayname", NS: nsDAV, Value: xmlEscape(cal.Name)}, {Name: "calendar-description", NS: nsCalDAV, Value: xmlEscape(cal.Description)}, {Name: "calendar-color", NS: "http://apple.com/ns/ical/", Value: xmlEscape(cal.Color)}, {Name: "supported-calendar-component-set", NS: nsCalDAV, Value: ``}, {Name: "getctag", NS: nsCS, Value: ctag}, {Name: "sync-token", NS: nsDAV, Value: xmlEscape(syncToken)}, }, } } func eventResponse(userID, calID int64, ev *models.CalendarEvent) davResponse { return davResponse{ Href: fmt.Sprintf("/caldav/%d/%d/%s.ics", userID, calID, ev.UID), Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ""}, {Name: "getetag", NS: nsDAV, Value: `"` + ev.ETag + `"`}, {Name: "getcontenttype", NS: nsDAV, Value: "text/calendar; charset=utf-8"}, {Name: "getlastmodified", NS: nsDAV, Value: ev.UpdatedAt.UTC().Format(http.TimeFormat)}, }, } } // ---- REPORT ---- func (s *Server) reportCalendar(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar) { body, err := io.ReadAll(io.LimitReader(r.Body, 256*1024)) if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } var req struct { XMLName xml.Name `xml:""` SyncToken string `xml:"sync-token"` Hrefs []string `xml:"href"` } _ = xml.Unmarshal(body, &req) localName := "" if req.XMLName.Local != "" { localName = req.XMLName.Local } switch localName { case "sync-collection": s.syncCollection(w, r, user, cal, req.SyncToken) case "calendar-multiget": s.calendarMultiget(w, r, user, cal, req.Hrefs) default: // calendar-query or unknown: return all events. s.propfindCalendar(w, r, user, cal) } } func (s *Server) syncCollection(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, clientToken string) { // Minimal sync: if token matches current, return 0 changes; else return all. curToken := fmt.Sprintf("https://example.com/ns/sync/%d", cal.SyncToken) if clientToken == curToken { // No changes since last sync. writeMultiStatus(w, []davResponse{}) return } // Full sync: return all events. events, err := s.deps.DB.ListCalendarEvents(r.Context(), cal.ID) if err != nil { log.Printf("[caldav] sync events: %v", err) } var responses []davResponse for _, ev := range events { responses = append(responses, eventResponse(user.ID, cal.ID, ev)) } writeMultiStatus(w, responses) } func (s *Server) calendarMultiget(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, hrefs []string) { var responses []davResponse for _, href := range hrefs { // Extract UID from href like /caldav/{uid}/{calid}/{uid}.ics parts := splitPath(strings.TrimPrefix(href, "/caldav")) if len(parts) < 3 { continue } uid := strings.TrimSuffix(parts[len(parts)-1], ".ics") ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid) if err != nil || ev == nil { responses = append(responses, davResponse{ Href: href, Status: "HTTP/1.1 404 Not Found", }) continue } // Return event with ical data. raw, err := s.decryptICal(user.ID, ev.ICalEnc) if err != nil { continue } res := eventResponse(user.ID, cal.ID, ev) res.Props = append(res.Props, davProp{ Name: "calendar-data", NS: nsCalDAV, Value: xmlEscape(string(raw)), CData: true, }) responses = append(responses, res) } writeMultiStatus(w, responses) } // ---- Event GET/PUT/DELETE ---- func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) { ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid) if err != nil || ev == nil { http.NotFound(w, r) return } // Conditional GET. if match := r.Header.Get("If-None-Match"); match != "" { if match == `"`+ev.ETag+`"` { w.WriteHeader(http.StatusNotModified) return } } raw, err := s.decryptICal(user.ID, ev.ICalEnc) if err != nil { http.Error(w, "decrypt error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/calendar; charset=utf-8") w.Header().Set("ETag", `"`+ev.ETag+`"`) w.Header().Set("Last-Modified", ev.UpdatedAt.UTC().Format(http.TimeFormat)) if r.Method == "HEAD" { w.Header().Set("Content-Length", strconv.Itoa(len(raw))) return } w.Write(raw) //nolint:errcheck } func (s *Server) putEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) { if r.ContentLength > 1024*1024 { http.Error(w, "Request too large", http.StatusRequestEntityTooLarge) return } raw, err := io.ReadAll(io.LimitReader(r.Body, 1024*1024)) if err != nil || len(raw) == 0 { http.Error(w, "read error", http.StatusBadRequest) return } // Validate it's iCalendar data. rawStr := string(raw) if !strings.Contains(rawStr, "BEGIN:VCALENDAR") { http.Error(w, "Invalid iCalendar data", http.StatusBadRequest) return } // Conditional check: If-Match must match existing ETag. existing, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" { if existing == nil || `"`+existing.ETag+`"` != ifMatch { http.Error(w, "Precondition Failed", http.StatusPreconditionFailed) return } } if r.Header.Get("If-None-Match") == "*" && existing != nil { http.Error(w, "Precondition Failed", http.StatusPreconditionFailed) return } // Parse minimal fields from iCalendar for DB index. dtStart, dtEnd, summary, recurring := parseICal(rawStr) // Encrypt and store. icalEnc, err := s.encryptICal(user.ID, raw) if err != nil { http.Error(w, "encrypt error", http.StatusInternalServerError) return } etag := sha256Hex(raw) if err := s.deps.DB.UpsertCalendarEvent(r.Context(), cal.ID, uid, etag, icalEnc, dtStart, dtEnd, summary, recurring); err != nil { log.Printf("[caldav] upsert event: %v", err) http.Error(w, "db error", http.StatusInternalServerError) return } if _, err := s.deps.DB.BumpCalendarSyncToken(r.Context(), cal.ID); err != nil { log.Printf("[caldav] bump token: %v", err) } w.Header().Set("ETag", `"`+etag+`"`) if existing == nil { w.WriteHeader(http.StatusCreated) } else { w.WriteHeader(http.StatusNoContent) } } func (s *Server) deleteEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) { ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid) if err != nil || ev == nil { http.NotFound(w, r) return } if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" { if `"`+ev.ETag+`"` != ifMatch { http.Error(w, "Precondition Failed", http.StatusPreconditionFailed) return } } if err := s.deps.DB.DeleteCalendarEvent(r.Context(), cal.ID, uid); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if _, err := s.deps.DB.BumpCalendarSyncToken(r.Context(), cal.ID); err != nil { log.Printf("[caldav] bump token delete: %v", err) } w.WriteHeader(http.StatusNoContent) } func (s *Server) mkcalendarHome(w http.ResponseWriter, r *http.Request, user *models.User) { // MKCOL on calendar home: ensure default calendar exists. if _, err := s.deps.DB.EnsureDefaultCalendar(r.Context(), user.ID); err != nil { http.Error(w, "error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) } // ---- Encryption helpers ---- func (s *Server) encryptICal(userID int64, plain []byte) ([]byte, error) { key, err := s.deps.Crypt.DeriveKey("ical", userID) if err != nil { return nil, err } return appCrypto.Encrypt(key, plain) } func (s *Server) decryptICal(userID int64, enc []byte) ([]byte, error) { key, err := s.deps.Crypt.DeriveKey("ical", userID) if err != nil { return nil, err } return appCrypto.Decrypt(key, enc) } // ---- iCalendar parsing (minimal, no third-party lib) ---- // parseICal extracts DTSTART, DTEND, SUMMARY, RRULE presence from raw iCal text. func parseICal(raw string) (dtStart, dtEnd time.Time, summary string, recurring bool) { for _, line := range strings.Split(raw, "\n") { line = strings.TrimRight(line, "\r") k, v, ok := strings.Cut(line, ":") if !ok { continue } // Strip parameters from key (e.g., DTSTART;TZID=UTC → DTSTART) k = strings.SplitN(k, ";", 2)[0] switch strings.ToUpper(k) { case "DTSTART": dtStart = parseICalTime(v) case "DTEND": dtEnd = parseICalTime(v) case "SUMMARY": summary = icalUnfold(v) case "RRULE": recurring = true } } return } func parseICalTime(s string) time.Time { s = strings.TrimSuffix(s, "Z") formats := []string{"20060102T150405", "20060102"} for _, f := range formats { if t, err := time.Parse(f, s); err == nil { return t.UTC() } } return time.Time{} } func icalUnfold(s string) string { return strings.ReplaceAll(s, `\n`, "\n") } // ---- XML multi-status response ---- type davProp struct { Name string NS string Value string CData bool // wrap Value in CDATA } type davResponse struct { Href string Status string // if empty, use 200 OK Props []davProp } func writeMultiStatus(w http.ResponseWriter, responses []davResponse) { w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.WriteHeader(http.StatusMultiStatus) fmt.Fprintf(w, ``+"\n") fmt.Fprintf(w, ``+"\n") for _, resp := range responses { fmt.Fprintf(w, " \n") fmt.Fprintf(w, " %s\n", xmlEscape(resp.Href)) if resp.Status != "" { fmt.Fprintf(w, " %s\n", xmlEscape(resp.Status)) } else if len(resp.Props) > 0 { fmt.Fprintf(w, " \n \n") for _, p := range resp.Props { writeDAVProp(w, p) } fmt.Fprintf(w, " \n HTTP/1.1 200 OK\n \n") } else { fmt.Fprintf(w, " HTTP/1.1 200 OK\n") } fmt.Fprintf(w, " \n") } fmt.Fprintf(w, "\n") } func writeDAVProp(w http.ResponseWriter, p davProp) { ns := "" switch p.NS { case nsDAV: ns = "D" case nsCalDAV: ns = "C" case nsCS: ns = "CS" case "http://apple.com/ns/ical/": ns = "ICAL" default: ns = "D" } if p.Value == "" { fmt.Fprintf(w, " <%s:%s/>\n", ns, p.Name) return } if p.CData { fmt.Fprintf(w, " <%s:%s>\n", ns, p.Name, p.Value, ns, p.Name) } else { fmt.Fprintf(w, " <%s:%s>%s\n", ns, p.Name, p.Value, ns, p.Name) } } // ---- helpers ---- func splitPath(path string) []string { var out []string for _, s := range strings.Split(strings.Trim(path, "/"), "/") { if s != "" { out = append(out, s) } } return out } func xmlEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, `"`, """) return s } func sha256Hex(data []byte) string { h := sha256.Sum256(data) return hex.EncodeToString(h[:]) }