Files

472 lines
15 KiB
Go
Raw Permalink Normal View History

2026-08-09 18:03:09 +01:00
// Package dav implements a CalDAV (RFC 4791) + CardDAV (RFC 6352) HTTP
// server covering the core operations real clients need: PROPFIND (Depth 0/1
// discovery), REPORT (calendar-query/multiget, addressbook-query/multiget —
// query filtering returns all objects in the collection in this pass, full
// time-range/property filtering deferred), PUT (create/update), GET
// (fetch), DELETE, OPTIONS. No MKCALENDAR/MKCOL — every user's default
// addressbook and calendar are auto-created on first access instead, which
// covers the common case (one addressbook, one calendar per user) without
// needing collection-creation UI in an early phase.
//
// URL layout:
//
// /dav/contacts/{ownerType}/{ownerID}/ addressbook collection
// /dav/contacts/{ownerType}/{ownerID}/{uid}.vcf a contact
// /dav/calendars/{ownerType}/{ownerID}/ calendar collection
// /dav/calendars/{ownerType}/{ownerID}/{uid}.ics a calendar event
package dav
import (
"encoding/xml"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"gomail/internal/auth"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/ical"
"gomail/internal/vcard"
"github.com/google/uuid"
)
type Handler struct {
database *db.DB
mk *crypto.MasterKey
}
func NewHandler(database *db.DB, mk *crypto.MasterKey) *Handler {
return &Handler{database: database, mk: mk}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticate(r)
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="GoMail DAV"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, "/dav")
switch {
case strings.HasPrefix(path, "/contacts/"):
h.serveCardDAV(w, r, user, strings.TrimPrefix(path, "/contacts/"))
case strings.HasPrefix(path, "/calendars/"):
h.serveCalDAV(w, r, user, strings.TrimPrefix(path, "/calendars/"))
default:
http.NotFound(w, r)
}
}
func (h *Handler) authenticate(r *http.Request) (*db.User, bool) {
username, password, ok := r.BasicAuth()
if !ok {
return nil, false
}
return auth.Authenticate(h.database, username, password, auth.ScopeCardDAV)
}
// ── CardDAV ───────────────────────────────────────────────────────────────────
func (h *Handler) serveCardDAV(w http.ResponseWriter, r *http.Request, user *db.User, rest string) {
ownerType, ownerID, uid, ok := parseCollectionPath(rest, user)
if !ok {
http.NotFound(w, r)
return
}
book, err := h.database.GetOrCreateAddressbook(ownerType, ownerID, "Default")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
switch r.Method {
case "OPTIONS":
w.Header().Set("DAV", "1, 2, addressbook")
w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE")
w.WriteHeader(http.StatusOK)
case "PROPFIND":
h.propfindContacts(w, r, book, uid)
case "REPORT":
h.reportContacts(w, r, book)
case http.MethodGet:
if uid == "" {
http.Error(w, "GET on collection not supported, use PROPFIND", http.StatusMethodNotAllowed)
return
}
contact, err := h.database.GetContact(book.ID, strings.TrimSuffix(uid, ".vcf"))
if err != nil {
http.NotFound(w, r)
return
}
plain, err := crypto.Decrypt(h.mk, contact.ID, "contact", contact.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", contact.ETag)
w.Write(plain)
case http.MethodPut:
if uid == "" {
http.Error(w, "PUT requires a resource path", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
card, err := vcard.Parse(string(body))
if err != nil {
http.Error(w, "invalid vCard: "+err.Error(), http.StatusBadRequest)
return
}
contactID := uuid.NewString()
if existing, err := h.database.GetContact(book.ID, card.UID); err == nil {
contactID = existing.ID
}
encrypted, err := crypto.Encrypt(h.mk, contactID, "contact", body)
if err != nil {
http.Error(w, "encrypt error", http.StatusInternalServerError)
return
}
etag := fmt.Sprintf(`"%d"`, time.Now().UnixNano())
if err := h.database.UpsertContact(&db.Contact{
ID: contactID, AddressbookID: book.ID, UID: card.UID, VCardEnc: encrypted, ETag: etag,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusCreated)
case http.MethodDelete:
if uid == "" {
http.Error(w, "DELETE requires a resource path", http.StatusBadRequest)
return
}
if err := h.database.DeleteContact(book.ID, strings.TrimSuffix(uid, ".vcf")); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) propfindContacts(w http.ResponseWriter, r *http.Request, book *db.Addressbook, uid string) {
depth := r.Header.Get("Depth")
var responses []multistatusResponse
responses = append(responses, multistatusResponse{
Href: r.URL.Path,
Props: propSet{
DisplayName: book.DisplayName,
ResourceType: "<collection xmlns=\"DAV:\"/><addressbook xmlns=\"urn:ietf:params:xml:ns:carddav\"/>",
},
})
if depth == "1" && uid == "" {
contacts, err := h.database.ListContacts(book.ID)
if err == nil {
for _, c := range contacts {
responses = append(responses, multistatusResponse{
Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + c.UID + ".vcf",
Props: propSet{ETag: c.ETag, ContentType: "text/vcard; charset=utf-8"},
})
}
}
}
writeMultistatus(w, responses)
}
func (h *Handler) reportContacts(w http.ResponseWriter, r *http.Request, book *db.Addressbook) {
// addressbook-query and addressbook-multiget both return every contact's
// current vCard in this pass — full filter/prop-match parsing is
// deferred; clients doing a multiget for hrefs they already have (the
// common sync pattern) get correct data, just not a filtered subset.
contacts, err := h.database.ListContacts(book.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var responses []multistatusResponse
for _, c := range contacts {
plain, err := crypto.Decrypt(h.mk, c.ID, "contact", c.VCardEnc)
if err != nil {
continue
}
responses = append(responses, multistatusResponse{
Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + c.UID + ".vcf",
Props: propSet{ETag: c.ETag},
AddressData: string(plain),
})
}
writeMultistatus(w, responses)
}
// ── CalDAV ────────────────────────────────────────────────────────────────────
func (h *Handler) serveCalDAV(w http.ResponseWriter, r *http.Request, user *db.User, rest string) {
ownerType, ownerID, uid, ok := parseCollectionPath(rest, user)
if !ok {
http.NotFound(w, r)
return
}
cal, err := h.database.GetOrCreateCalendar(ownerType, ownerID, "Default")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
switch r.Method {
case "OPTIONS":
w.Header().Set("DAV", "1, 2, calendar-access")
w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE")
w.WriteHeader(http.StatusOK)
case "PROPFIND":
h.propfindCalendar(w, r, cal, uid)
case "REPORT":
h.reportCalendar(w, r, cal)
case http.MethodGet:
if uid == "" {
http.Error(w, "GET on collection not supported, use PROPFIND", http.StatusMethodNotAllowed)
return
}
obj, err := h.database.GetCalendarObject(cal.ID, strings.TrimSuffix(uid, ".ics"))
if err != nil {
http.NotFound(w, r)
return
}
plain, err := crypto.Decrypt(h.mk, obj.ID, "calendar", obj.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", obj.ETag)
w.Write(plain)
case http.MethodPut:
if uid == "" {
http.Error(w, "PUT requires a resource path", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
event, err := ical.Parse(string(body))
if err != nil {
http.Error(w, "invalid iCal: "+err.Error(), http.StatusBadRequest)
return
}
objID := uuid.NewString()
if existing, err := h.database.GetCalendarObject(cal.ID, event.UID); err == nil {
objID = existing.ID
}
encrypted, err := crypto.Encrypt(h.mk, objID, "calendar", body)
if err != nil {
http.Error(w, "encrypt error", http.StatusInternalServerError)
return
}
etag := fmt.Sprintf(`"%d"`, time.Now().UnixNano())
obj := &db.CalendarObject{
ID: objID, CalendarID: cal.ID, UID: event.UID, ICalEnc: encrypted,
ComponentType: "VEVENT", Summary: event.Summary, ETag: etag,
}
if !event.DTStart.IsZero() {
obj.DTStart = &event.DTStart
}
if !event.DTEnd.IsZero() {
obj.DTEnd = &event.DTEnd
}
if err := h.database.UpsertCalendarObject(obj); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusCreated)
case http.MethodDelete:
if uid == "" {
http.Error(w, "DELETE requires a resource path", http.StatusBadRequest)
return
}
if err := h.database.DeleteCalendarObject(cal.ID, strings.TrimSuffix(uid, ".ics")); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) propfindCalendar(w http.ResponseWriter, r *http.Request, cal *db.Calendar, uid string) {
depth := r.Header.Get("Depth")
var responses []multistatusResponse
responses = append(responses, multistatusResponse{
Href: r.URL.Path,
Props: propSet{
DisplayName: cal.DisplayName,
ResourceType: "<collection xmlns=\"DAV:\"/><calendar xmlns=\"urn:ietf:params:xml:ns:caldav\"/>",
},
})
if depth == "1" && uid == "" {
objs, err := h.database.ListCalendarObjects(cal.ID)
if err == nil {
for _, o := range objs {
responses = append(responses, multistatusResponse{
Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + o.UID + ".ics",
Props: propSet{ETag: o.ETag, ContentType: "text/calendar; charset=utf-8"},
})
}
}
}
writeMultistatus(w, responses)
}
func (h *Handler) reportCalendar(w http.ResponseWriter, r *http.Request, cal *db.Calendar) {
// calendar-query and calendar-multiget both return every event in this
// pass — time-range filtering (the most common real-world calendar-query
// use, "give me events this week") is deferred; noted here rather than
// silently ignored, since clients that rely on server-side time-range
// filtering will over-fetch until that lands.
objs, err := h.database.ListCalendarObjects(cal.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var responses []multistatusResponse
for _, o := range objs {
plain, err := crypto.Decrypt(h.mk, o.ID, "calendar", o.ICalEnc)
if err != nil {
continue
}
responses = append(responses, multistatusResponse{
Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + o.UID + ".ics",
Props: propSet{ETag: o.ETag},
CalendarData: string(plain),
})
}
writeMultistatus(w, responses)
}
// ── Path parsing ──────────────────────────────────────────────────────────────
// parseCollectionPath extracts (ownerType, ownerID, resourceUID) from a
// request path like "user/{userID}/{uid}.vcf" or "tenant/{tenantID}/". Only
// allows a user to address their own personal collection or their own
// tenant's shared one — cross-user access is rejected.
func parseCollectionPath(rest string, requestingUser *db.User) (db.OwnerType, string, string, bool) {
parts := strings.SplitN(strings.TrimPrefix(rest, "/"), "/", 3)
if len(parts) < 2 {
return "", "", "", false
}
ownerType := db.OwnerType(parts[0])
ownerID := parts[1]
uid := ""
if len(parts) == 3 {
uid = parts[2]
}
switch ownerType {
case db.OwnerUser:
if ownerID != requestingUser.ID {
return "", "", "", false // no cross-user access
}
case db.OwnerTenant:
if ownerID != requestingUser.TenantID {
return "", "", "", false // no cross-tenant access
}
default:
return "", "", "", false
}
return ownerType, ownerID, uid, true
}
// ── Multistatus XML ───────────────────────────────────────────────────────────
type propSet struct {
DisplayName string
ResourceType string // raw XML fragment, since it varies by collection type
ETag string
ContentType string
}
type multistatusResponse struct {
Href string
Props propSet
AddressData string // set only for CardDAV REPORT responses
CalendarData string // set only for CalDAV REPORT responses
}
func writeMultistatus(w http.ResponseWriter, responses []multistatusResponse) {
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav" xmlns:CAL="urn:ietf:params:xml:ns:caldav">` + "\n")
for _, r := range responses {
b.WriteString(" <D:response>\n")
b.WriteString(" <D:href>" + xmlEscape(r.Href) + "</D:href>\n")
b.WriteString(" <D:propstat>\n <D:prop>\n")
if r.Props.DisplayName != "" {
b.WriteString(" <D:displayname>" + xmlEscape(r.Props.DisplayName) + "</D:displayname>\n")
}
if r.Props.ResourceType != "" {
b.WriteString(" <D:resourcetype>" + r.Props.ResourceType + "</D:resourcetype>\n")
}
if r.Props.ETag != "" {
b.WriteString(" <D:getetag>" + xmlEscape(r.Props.ETag) + "</D:getetag>\n")
}
if r.Props.ContentType != "" {
b.WriteString(" <D:getcontenttype>" + xmlEscape(r.Props.ContentType) + "</D:getcontenttype>\n")
}
if r.AddressData != "" {
b.WriteString(" <C:address-data>" + xmlEscape(r.AddressData) + "</C:address-data>\n")
}
if r.CalendarData != "" {
b.WriteString(" <CAL:calendar-data>" + xmlEscape(r.CalendarData) + "</CAL:calendar-data>\n")
}
b.WriteString(" </D:prop>\n <D:status>HTTP/1.1 200 OK</D:status>\n </D:propstat>\n")
b.WriteString(" </D:response>\n")
}
b.WriteString("</D:multistatus>\n")
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(207) // Multi-Status
if _, err := w.Write([]byte(b.String())); err != nil {
slog.Debug("dav: write error", "err", err)
}
}
func xmlEscape(s string) string {
var b strings.Builder
xml.EscapeText(&b, []byte(s))
return b.String()
}