Files
mailgosend/internal/carddav/server.go
T
2026-05-22 06:06:44 +00:00

615 lines
17 KiB
Go

// 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: "<D:collection/>"},
{Name: "displayname", NS: nsDAV, Value: "CardDAV"},
{Name: "current-user-principal", NS: nsDAV,
Value: fmt.Sprintf("<D:href>/carddav/p/%d</D:href>", 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: "<D:principal/>"},
{Name: "displayname", NS: nsDAV, Value: xmlEscape(user.DisplayName)},
{Name: "current-user-principal", NS: nsDAV,
Value: fmt.Sprintf("<D:href>/carddav/p/%d</D:href>", user.ID)},
{Name: "addressbook-home-set", NS: nsCardDAV,
Value: fmt.Sprintf("<D:href>/carddav/%d/</D:href>", 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: "<D:collection/>"},
{Name: "displayname", NS: nsDAV, Value: "Address Books"},
{Name: "addressbook-home-set", NS: nsCardDAV,
Value: fmt.Sprintf("<D:href>/carddav/%d/</D:href>", 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: `<D:collection/><CARD:addressbook xmlns:CARD="` + nsCardDAV + `"/>`},
{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: `<CARD:address-data-type xmlns:CARD="` + nsCardDAV + `" content-type="text/vcard" version="3.0"/>`},
},
}
}
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, `<?xml version="1.0" encoding="UTF-8"?>`+"\n")
fmt.Fprintf(w, `<D:multistatus xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/">`+"\n")
for _, resp := range responses {
fmt.Fprintf(w, " <D:response>\n")
fmt.Fprintf(w, " <D:href>%s</D:href>\n", xmlEscape(resp.Href))
if resp.Status != "" {
fmt.Fprintf(w, " <D:status>%s</D:status>\n", xmlEscape(resp.Status))
} else if len(resp.Props) > 0 {
fmt.Fprintf(w, " <D:propstat>\n <D:prop>\n")
for _, p := range resp.Props {
writeDAVProp(w, p)
}
fmt.Fprintf(w, " </D:prop>\n <D:status>HTTP/1.1 200 OK</D:status>\n </D:propstat>\n")
} else {
fmt.Fprintf(w, " <D:status>HTTP/1.1 200 OK</D:status>\n")
}
fmt.Fprintf(w, " </D:response>\n")
}
fmt.Fprintf(w, "</D:multistatus>\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><![CDATA[%s]]></%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
} else {
fmt.Fprintf(w, " <%s:%s>%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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func sha256Hex(data []byte) string {
h := gocrypto.Sum256(data)
return hex.EncodeToString(h[:])
}