first commit
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user