472 lines
15 KiB
Go
472 lines
15 KiB
Go
// 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()
|
||
|
|
}
|