go-webdav/internal/client.go
2023-12-27 23:16:49 +01:00

257 lines
6.2 KiB
Go

package internal
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"path"
"strings"
"unicode"
)
// DiscoverContextURL performs a DNS-based CardDAV/CalDAV service discovery as
// described in RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, service, domain string) (string, error) {
var resolver net.Resolver
// Only lookup TLS records, plaintext connections are insecure
_, addrs, err := resolver.LookupSRV(ctx, service+"s", "tcp", domain)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsTemporary {
return "", err
}
} else if err != nil {
return "", err
}
if len(addrs) == 0 {
return "", fmt.Errorf("webdav: domain doesn't have an SRV record")
}
addr := addrs[0]
target := strings.TrimSuffix(addr.Target, ".")
if target == "" {
return "", fmt.Errorf("webdav: empty target in SRV record")
}
// TODO: perform a TXT lookup, check for a "path" key in the response
u := url.URL{Scheme: "https"}
if addr.Port == 443 {
u.Host = target
} else {
u.Host = fmt.Sprintf("%v:%v", target, addr.Port)
}
u.Path = "/.well-known/" + service
return u.String(), nil
}
// HTTPClient performs HTTP requests. It's implemented by *http.Client.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type Client struct {
http HTTPClient
endpoint *url.URL
}
func NewClient(c HTTPClient, endpoint string) (*Client, error) {
if c == nil {
c = http.DefaultClient
}
u, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
if u.Path == "" {
// This is important to avoid issues with path.Join
u.Path = "/"
}
return &Client{http: c, endpoint: u}, nil
}
func (c *Client) ResolveHref(p string) *url.URL {
if !strings.HasPrefix(p, "/") {
p = path.Join(c.endpoint.Path, p)
}
return &url.URL{
Scheme: c.endpoint.Scheme,
User: c.endpoint.User,
Host: c.endpoint.Host,
Path: p,
}
}
func (c *Client) NewRequest(method string, path string, body io.Reader) (*http.Request, error) {
return http.NewRequest(method, c.ResolveHref(path).String(), body)
}
func (c *Client) NewXMLRequest(method string, path string, v interface{}) (*http.Request, error) {
var buf bytes.Buffer
buf.WriteString(xml.Header)
if err := xml.NewEncoder(&buf).Encode(v); err != nil {
return nil, err
}
req, err := c.NewRequest(method, path, &buf)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "text/xml; charset=\"utf-8\"")
return req, nil
}
func (c *Client) Do(req *http.Request) (*http.Response, error) {
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode/100 != 2 {
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "text/plain"
}
var wrappedErr error
t, _, _ := mime.ParseMediaType(contentType)
if t == "application/xml" || t == "text/xml" {
var davErr Error
if err := xml.NewDecoder(resp.Body).Decode(&davErr); err != nil {
wrappedErr = err
} else {
wrappedErr = &davErr
}
} else if strings.HasPrefix(t, "text/") {
lr := io.LimitedReader{R: resp.Body, N: 1024}
var buf bytes.Buffer
io.Copy(&buf, &lr)
resp.Body.Close()
if s := strings.TrimSpace(buf.String()); s != "" {
if lr.N == 0 {
s += " […]"
}
wrappedErr = fmt.Errorf("%v", s)
}
}
return nil, &HTTPError{Code: resp.StatusCode, Err: wrappedErr}
}
return resp, nil
}
func (c *Client) DoMultiStatus(req *http.Request) (*MultiStatus, error) {
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
return nil, fmt.Errorf("HTTP multi-status request failed: %v", resp.Status)
}
// TODO: the response can be quite large, support streaming Response elements
var ms MultiStatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return nil, err
}
return &ms, nil
}
func (c *Client) PropFind(ctx context.Context, path string, depth Depth, propfind *PropFind) (*MultiStatus, error) {
req, err := c.NewXMLRequest("PROPFIND", path, propfind)
if err != nil {
return nil, err
}
req.Header.Add("Depth", depth.String())
return c.DoMultiStatus(req.WithContext(ctx))
}
// PropfindFlat performs a PROPFIND request with a zero depth.
func (c *Client) PropFindFlat(ctx context.Context, path string, propfind *PropFind) (*Response, error) {
ms, err := c.PropFind(ctx, path, DepthZero, propfind)
if err != nil {
return nil, err
}
// If the client followed a redirect, the Href might be different from the request path
if len(ms.Responses) != 1 {
return nil, fmt.Errorf("PROPFIND with Depth: 0 returned %d responses", len(ms.Responses))
}
return &ms.Responses[0], nil
}
func parseCommaSeparatedSet(values []string, upper bool) map[string]bool {
m := make(map[string]bool)
for _, v := range values {
fields := strings.FieldsFunc(v, func(r rune) bool {
return unicode.IsSpace(r) || r == ','
})
for _, f := range fields {
if upper {
f = strings.ToUpper(f)
} else {
f = strings.ToLower(f)
}
m[f] = true
}
}
return m
}
func (c *Client) Options(ctx context.Context, path string) (classes map[string]bool, methods map[string]bool, err error) {
req, err := c.NewRequest(http.MethodOptions, path, nil)
if err != nil {
return nil, nil, err
}
resp, err := c.Do(req.WithContext(ctx))
if err != nil {
return nil, nil, err
}
resp.Body.Close()
classes = parseCommaSeparatedSet(resp.Header["Dav"], false)
if !classes["1"] {
return nil, nil, fmt.Errorf("webdav: server doesn't support DAV class 1")
}
methods = parseCommaSeparatedSet(resp.Header["Allow"], true)
return classes, methods, nil
}
// SyncCollection perform a `sync-collection` REPORT operation on a resource
func (c *Client) SyncCollection(ctx context.Context, path, syncToken string, level Depth, limit *Limit, prop *Prop) (*MultiStatus, error) {
q := SyncCollectionQuery{
SyncToken: syncToken,
SyncLevel: level.String(),
Limit: limit,
Prop: prop,
}
req, err := c.NewXMLRequest("REPORT", path, &q)
if err != nil {
return nil, err
}
ms, err := c.DoMultiStatus(req.WithContext(ctx))
if err != nil {
return nil, err
}
return ms, nil
}