615 lines
17 KiB
Go
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, "&", "&")
|
|
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[:])
|
|
}
|