// Package carddav implements a CardDAV server (RFC 6352 over WebDAV RFC 4918). // Authentication: HTTP Basic Auth against the user DB. // Contacts are stored as AES-256-GCM encrypted vCard blobs. package carddav import ( "context" gocrypto "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:" nsCardDAV = "urn:ietf:params:xml:ns:carddav" nsCS = "http://calendarserver.org/ns/" ) // Deps holds CardDAV server dependencies. type Deps struct { DB *db.DB Crypt *appCrypto.Crypto } // Server is the CardDAV HTTP handler. type Server struct { deps *Deps mux *http.ServeMux } // New creates a CardDAV server. 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() { s.mux.HandleFunc("/.well-known/carddav", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/carddav/", http.StatusMovedPermanently) }) s.mux.HandleFunc("/carddav/", s.withAuth(s.route)) } 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 CardDAV", 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") } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() user, err := s.deps.DB.GetUserByEmail(ctx, strings.TrimSpace(parts[0])) if err != nil || user == nil { return nil, err } if err := appCrypto.CheckPassword(user.PasswordHash, parts[1]); err != nil { return nil, nil } return user, nil } // route dispatches CardDAV requests. // Paths under /carddav/: // /carddav/ → root // /carddav/p/{userID} → principal // /carddav/{userID}/ → address book home // /carddav/{userID}/{abID}/ → address book collection // /carddav/{userID}/{abID}/{uid}.vcf → contact resource func (s *Server) route(w http.ResponseWriter, r *http.Request, user *models.User) { path := strings.TrimPrefix(r.URL.Path, "/carddav") path = strings.TrimSuffix(path, "/") segments := splitPath(path) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() r = r.WithContext(ctx) const allow = "OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL, REPORT" switch len(segments) { case 0: s.setDavHeaders(w) if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } if r.Method == "PROPFIND" { s.propfindRoot(w, r, user) return } http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) case 1: if segments[0] == "p" { http.Redirect(w, r, fmt.Sprintf("/carddav/p/%d", user.ID), http.StatusMovedPermanently) return } ownerID, err := strconv.ParseInt(segments[0], 10, 64) if err != nil || ownerID != user.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } s.setDavHeaders(w) switch r.Method { case "OPTIONS": w.WriteHeader(http.StatusOK) case "PROPFIND": s.propfindHome(w, r, user) default: w.Header().Set("Allow", allow) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case 2: if segments[0] == "p" { s.setDavHeaders(w) if r.Method == "PROPFIND" { s.propfindPrincipal(w, r, user) return } w.WriteHeader(http.StatusOK) return } ownerID, _ := strconv.ParseInt(segments[0], 10, 64) if ownerID != user.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } abID, err := strconv.ParseInt(segments[1], 10, 64) if err != nil { http.NotFound(w, r) return } ab, err := s.deps.DB.GetAddressBookByID(r.Context(), abID) if err != nil || ab == nil || ab.UserID != user.ID { http.NotFound(w, r) return } s.setDavHeaders(w) switch r.Method { case "OPTIONS": w.WriteHeader(http.StatusOK) case "PROPFIND": s.propfindAddressBook(w, r, user, ab) case "REPORT": s.reportAddressBook(w, r, user, ab) case "DELETE": s.deps.DB.DeleteAddressBook(r.Context(), abID) //nolint:errcheck w.WriteHeader(http.StatusNoContent) default: w.Header().Set("Allow", allow) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case 3: ownerID, _ := strconv.ParseInt(segments[0], 10, 64) if ownerID != user.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } abID, err := strconv.ParseInt(segments[1], 10, 64) if err != nil { http.NotFound(w, r) return } ab, err := s.deps.DB.GetAddressBookByID(r.Context(), abID) if err != nil || ab == nil || ab.UserID != user.ID { http.NotFound(w, r) return } uid := strings.TrimSuffix(segments[2], ".vcf") switch r.Method { case "GET", "HEAD": s.getContact(w, r, user, ab, uid) case "PUT": s.putContact(w, r, user, ab, uid) case "DELETE": s.deleteContact(w, r, user, ab, uid) case "OPTIONS": s.setDavHeaders(w) w.WriteHeader(http.StatusOK) default: w.Header().Set("Allow", allow) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } default: http.NotFound(w, r) } } func (s *Server) setDavHeaders(w http.ResponseWriter) { w.Header().Set("DAV", "1, 2, 3, addressbook") w.Header().Set("Allow", "OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL, REPORT") w.Header().Set("Ms-Author-Via", "DAV") } // ---- PROPFIND handlers ---- func (s *Server) propfindRoot(w http.ResponseWriter, r *http.Request, user *models.User) { writeMultiStatus(w, []davResponse{ { Href: "/carddav/", Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ""}, {Name: "displayname", NS: nsDAV, Value: "CardDAV"}, {Name: "current-user-principal", NS: nsDAV, Value: fmt.Sprintf("/carddav/p/%d", user.ID)}, }, }, }) } func (s *Server) propfindPrincipal(w http.ResponseWriter, r *http.Request, user *models.User) { writeMultiStatus(w, []davResponse{ { Href: fmt.Sprintf("/carddav/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("/carddav/p/%d", user.ID)}, {Name: "addressbook-home-set", NS: nsCardDAV, Value: fmt.Sprintf("/carddav/%d/", user.ID)}, }, }, }) } func (s *Server) propfindHome(w http.ResponseWriter, r *http.Request, user *models.User) { depth := r.Header.Get("Depth") abs, err := s.deps.DB.ListAddressBooks(r.Context(), user.ID) if err != nil { log.Printf("[carddav] list address books: %v", err) } responses := []davResponse{ { Href: fmt.Sprintf("/carddav/%d/", user.ID), Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ""}, {Name: "displayname", NS: nsDAV, Value: "Address Books"}, {Name: "addressbook-home-set", NS: nsCardDAV, Value: fmt.Sprintf("/carddav/%d/", user.ID)}, }, }, } if depth != "0" { for _, ab := range abs { responses = append(responses, addressBookResponse(user.ID, ab)) } } writeMultiStatus(w, responses) } func (s *Server) propfindAddressBook(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook) { depth := r.Header.Get("Depth") responses := []davResponse{addressBookResponse(user.ID, ab)} if depth != "0" { contacts, err := s.deps.DB.ListContacts(r.Context(), ab.ID) if err != nil { log.Printf("[carddav] list contacts: %v", err) } for _, c := range contacts { responses = append(responses, contactResponse(user.ID, ab.ID, c)) } } writeMultiStatus(w, responses) } func addressBookResponse(userID int64, ab *models.AddressBook) davResponse { ctag := strconv.FormatInt(ab.SyncToken, 10) return davResponse{ Href: fmt.Sprintf("/carddav/%d/%d/", userID, ab.ID), Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ``}, {Name: "displayname", NS: nsDAV, Value: xmlEscape(ab.Name)}, {Name: "addressbook-description", NS: nsCardDAV, Value: xmlEscape(ab.Description)}, {Name: "getctag", NS: nsCS, Value: ctag}, {Name: "sync-token", NS: nsDAV, Value: fmt.Sprintf("https://example.com/ns/sync/%d", ab.SyncToken)}, {Name: "supported-address-data", NS: nsCardDAV, Value: ``}, }, } } func contactResponse(userID, abID int64, c *models.Contact) davResponse { return davResponse{ Href: fmt.Sprintf("/carddav/%d/%d/%s.vcf", userID, abID, c.UID), Props: []davProp{ {Name: "resourcetype", NS: nsDAV, Value: ""}, {Name: "getetag", NS: nsDAV, Value: `"` + c.ETag + `"`}, {Name: "getcontenttype", NS: nsDAV, Value: "text/vcard; charset=utf-8"}, {Name: "getlastmodified", NS: nsDAV, Value: c.UpdatedAt.UTC().Format(http.TimeFormat)}, }, } } // ---- REPORT ---- func (s *Server) reportAddressBook(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook) { 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) switch req.XMLName.Local { case "sync-collection": curToken := fmt.Sprintf("https://example.com/ns/sync/%d", ab.SyncToken) if req.SyncToken == curToken { writeMultiStatus(w, []davResponse{}) return } contacts, err := s.deps.DB.ListContacts(r.Context(), ab.ID) if err != nil { log.Printf("[carddav] sync contacts: %v", err) } var responses []davResponse for _, c := range contacts { responses = append(responses, contactResponse(user.ID, ab.ID, c)) } writeMultiStatus(w, responses) case "addressbook-multiget": var responses []davResponse for _, href := range req.Hrefs { parts := splitPath(strings.TrimPrefix(href, "/carddav")) if len(parts) < 3 { continue } uid := strings.TrimSuffix(parts[len(parts)-1], ".vcf") c, err := s.deps.DB.GetContact(r.Context(), ab.ID, uid) if err != nil || c == nil { responses = append(responses, davResponse{ Href: href, Status: "HTTP/1.1 404 Not Found", }) continue } raw, err := s.decryptVCard(user.ID, c.VCardEnc) if err != nil { continue } res := contactResponse(user.ID, ab.ID, c) res.Props = append(res.Props, davProp{ Name: "address-data", NS: nsCardDAV, Value: xmlEscape(string(raw)), CData: true, }) responses = append(responses, res) } writeMultiStatus(w, responses) default: s.propfindAddressBook(w, r, user, ab) } } // ---- Contact GET/PUT/DELETE ---- func (s *Server) getContact(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook, uid string) { c, err := s.deps.DB.GetContact(r.Context(), ab.ID, uid) if err != nil || c == nil { http.NotFound(w, r) return } if match := r.Header.Get("If-None-Match"); match == `"`+c.ETag+`"` { w.WriteHeader(http.StatusNotModified) return } raw, err := s.decryptVCard(user.ID, c.VCardEnc) if err != nil { http.Error(w, "decrypt error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/vcard; charset=utf-8") w.Header().Set("ETag", `"`+c.ETag+`"`) w.Header().Set("Last-Modified", c.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) putContact(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook, uid string) { if r.ContentLength > 512*1024 { http.Error(w, "Request too large", http.StatusRequestEntityTooLarge) return } raw, err := io.ReadAll(io.LimitReader(r.Body, 512*1024)) if err != nil || len(raw) == 0 { http.Error(w, "read error", http.StatusBadRequest) return } if !strings.Contains(string(raw), "BEGIN:VCARD") { http.Error(w, "Invalid vCard data", http.StatusBadRequest) return } existing, err := s.deps.DB.GetContact(r.Context(), ab.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 } vcardEnc, err := s.encryptVCard(user.ID, raw) if err != nil { http.Error(w, "encrypt error", http.StatusInternalServerError) return } etag := sha256Hex(raw) if err := s.deps.DB.UpsertContact(r.Context(), ab.ID, uid, etag, vcardEnc); err != nil { log.Printf("[carddav] upsert contact: %v", err) http.Error(w, "db error", http.StatusInternalServerError) return } if _, err := s.deps.DB.BumpAddressBookSyncToken(r.Context(), ab.ID); err != nil { log.Printf("[carddav] bump token: %v", err) } w.Header().Set("ETag", `"`+etag+`"`) if existing == nil { w.WriteHeader(http.StatusCreated) } else { w.WriteHeader(http.StatusNoContent) } } func (s *Server) deleteContact(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook, uid string) { c, err := s.deps.DB.GetContact(r.Context(), ab.ID, uid) if err != nil || c == nil { http.NotFound(w, r) return } if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" { if `"`+c.ETag+`"` != ifMatch { http.Error(w, "Precondition Failed", http.StatusPreconditionFailed) return } } if err := s.deps.DB.DeleteContact(r.Context(), ab.ID, uid); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if _, err := s.deps.DB.BumpAddressBookSyncToken(r.Context(), ab.ID); err != nil { log.Printf("[carddav] bump token delete: %v", err) } w.WriteHeader(http.StatusNoContent) } // ---- Encryption ---- func (s *Server) encryptVCard(userID int64, plain []byte) ([]byte, error) { key, err := s.deps.Crypt.DeriveKey("vcard", userID) if err != nil { return nil, err } return appCrypto.Encrypt(key, plain) } func (s *Server) decryptVCard(userID int64, enc []byte) ([]byte, error) { key, err := s.deps.Crypt.DeriveKey("vcard", userID) if err != nil { return nil, err } return appCrypto.Decrypt(key, enc) } // ---- XML helpers (shared pattern with caldav) ---- type davProp struct { Name string NS string Value string CData bool } type davResponse struct { Href string Status string 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 := "D" switch p.NS { case nsCardDAV: ns = "CARD" case nsCS: ns = "CS" } 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) } } 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 := gocrypto.Sum256(data) return hex.EncodeToString(h[:]) }