From ae93da82c10b26bae0bfdb9f126e9053c6ab4829 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 15 Jan 2020 18:21:27 +0100 Subject: [PATCH] webdav: add minimal server implementation --- fs_local.go | 32 ++++++++ internal/client.go | 13 ++++ internal/elements.go | 84 +++++++++++++++++++-- server.go | 175 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 fs_local.go create mode 100644 server.go diff --git a/fs_local.go b/fs_local.go new file mode 100644 index 0000000..bf3a097 --- /dev/null +++ b/fs_local.go @@ -0,0 +1,32 @@ +package webdav + +import ( + "net/http" + "os" + "path" + "path/filepath" + "strings" +) + +type LocalFileSystem string + +func (fs LocalFileSystem) path(name string) (string, error) { + if (filepath.Separator != '/' && strings.IndexRune(name, filepath.Separator) >= 0) || strings.Contains(name, "\x00") { + return "", HTTPErrorf(http.StatusBadRequest, "webdav: invalid character in path") + } + name = path.Clean(name) + if !path.IsAbs(name) { + return "", HTTPErrorf(http.StatusBadRequest, "webdav: expected absolute path") + } + return filepath.Join(string(fs), filepath.FromSlash(name)), nil +} + +func (fs LocalFileSystem) Open(name string) (File, error) { + p, err := fs.path(name) + if err != nil { + return nil, err + } + return os.Open(p) +} + +var _ FileSystem = LocalFileSystem("") diff --git a/internal/client.go b/internal/client.go index c00fb61..442d699 100644 --- a/internal/client.go +++ b/internal/client.go @@ -25,6 +25,19 @@ const ( DepthInfinity Depth = -1 ) +// ParseDepth parses a Depth header. +func ParseDepth(s string) (Depth, error) { + switch s { + case "0": + return DepthZero, nil + case "1": + return DepthOne, nil + case "infinity": + return DepthInfinity, nil + } + return 0, fmt.Errorf("webdav: invalid Depth value") +} + // String formats the depth. func (d Depth) String() string { switch d { diff --git a/internal/elements.go b/internal/elements.go index 43f8ad1..fb9072e 100644 --- a/internal/elements.go +++ b/internal/elements.go @@ -8,23 +8,40 @@ import ( "strings" ) +// TODO: cache parsed value type Status string -func (s Status) Err() error { +func NewStatus(code int, msg string) Status { + if msg == "" { + msg = http.StatusText(code) + } + return Status(fmt.Sprintf("HTTP/1.1 %v %v", code, msg)) +} + +func (s Status) parse() (int, string, error) { if s == "" { - return nil + return http.StatusOK, "", nil } parts := strings.SplitN(string(s), " ", 3) if len(parts) != 3 { - return fmt.Errorf("webdav: invalid HTTP status %q: expected 3 fields", s) + return 0, "", fmt.Errorf("webdav: invalid HTTP status %q: expected 3 fields", s) } code, err := strconv.Atoi(parts[1]) if err != nil { - return fmt.Errorf("webdav: invalid HTTP status %q: failed to parse code: %v", s, err) + return 0, "", fmt.Errorf("webdav: invalid HTTP status %q: failed to parse code: %v", s, err) } msg := parts[2] + return code, msg, nil +} + +func (s Status) Err() error { + code, msg, err := s.parse() + if err != nil { + return err + } + // TODO: handle 2xx, 3xx if code != http.StatusOK { return fmt.Errorf("webdav: HTTP error: %v %v", code, msg) @@ -39,6 +56,10 @@ type Multistatus struct { ResponseDescription string `xml:"responsedescription,omitempty"` } +func NewMultistatus(resps ...Response) *Multistatus { + return &Multistatus{Responses: resps} +} + func (ms *Multistatus) Get(href string) (*Response, error) { for i := range ms.Responses { resp := &ms.Responses[i] @@ -63,6 +84,13 @@ type Response struct { Location *Location `xml:"location,omitempty"` } +func NewOKResponse(href string) *Response { + return &Response{ + Hrefs: []string{href}, + Status: NewStatus(http.StatusOK, ""), + } +} + func (resp *Response) Href() (string, error) { if err := resp.Status.Err(); err != nil { return "", err @@ -97,6 +125,28 @@ func (resp *Response) DecodeProp(v interface{}) error { return fmt.Errorf("webdav: missing prop %v %v in response", name.Space, name.Local) } +func (resp *Response) EncodeProp(code int, v interface{}) error { + raw, err := EncodeRawXMLElement(v) + if err != nil { + return err + } + + for i := range resp.Propstats { + propstat := &resp.Propstats[i] + c, _, _ := propstat.Status.parse() + if c == code { + propstat.Prop.Raw = append(propstat.Prop.Raw, *raw) + return nil + } + } + + resp.Propstats = append(resp.Propstats, Propstat{ + Status: NewStatus(code, ""), + Prop: Prop{Raw: []RawXMLValue{*raw}}, + }) + return nil +} + // https://tools.ietf.org/html/rfc4918#section-14.9 type Location struct { XMLName xml.Name `xml:"DAV: location"` @@ -130,6 +180,16 @@ func EncodeProp(values ...interface{}) (*Prop, error) { return &Prop{Raw: l}, nil } +func (prop *Prop) XMLNames() []xml.Name { + l := make([]xml.Name, 0, len(prop.Raw)) + for _, raw := range prop.Raw { + if start, ok := raw.tok.(xml.StartElement); ok { + l = append(l, start.Name) + } + } + return l +} + // https://tools.ietf.org/html/rfc4918#section-14.20 type Propfind struct { XMLName xml.Name `xml:"DAV: propfind"` @@ -137,12 +197,16 @@ type Propfind struct { // TODO: propname | (allprop, include?) } -func NewPropNamePropfind(names ...xml.Name) *Propfind { - children := make([]RawXMLValue, len(names)) +func xmlNamesToRaw(names []xml.Name) []RawXMLValue { + l := make([]RawXMLValue, len(names)) for i, name := range names { - children[i] = *NewRawXMLElement(name, nil, nil) + l[i] = *NewRawXMLElement(name, nil, nil) } - return &Propfind{Prop: &Prop{Raw: children}} + return l +} + +func NewPropNamePropfind(names ...xml.Name) *Propfind { + return &Propfind{Prop: &Prop{Raw: xmlNamesToRaw(names)}} } // https://tools.ietf.org/html/rfc4918#section-15.9 @@ -151,6 +215,10 @@ type ResourceType struct { Raw []RawXMLValue `xml:",any"` } +func NewResourceType(names ...xml.Name) *ResourceType { + return &ResourceType{Raw: xmlNamesToRaw(names)} +} + func (t *ResourceType) Is(name xml.Name) bool { for _, raw := range t.Raw { if start, ok := raw.tok.(xml.StartElement); ok && name == start.Name { diff --git a/server.go b/server.go new file mode 100644 index 0000000..e455aeb --- /dev/null +++ b/server.go @@ -0,0 +1,175 @@ +package webdav + +import ( + "encoding/xml" + "fmt" + "mime" + "net/http" + "os" + + "github.com/emersion/go-webdav/internal" +) + +type HTTPError struct { + Code int + Err error +} + +func HTTPErrorf(code int, format string, a ...interface{}) *HTTPError { + return &HTTPError{code, fmt.Errorf(format, a...)} +} + +func (err *HTTPError) Error() string { + return fmt.Sprintf("%v %v: %v", err.Code, http.StatusText(err.Code), err.Err) +} + +type File interface { + http.File +} + +type FileSystem interface { + Open(name string) (File, error) +} + +type Handler struct { + FileSystem FileSystem +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var err error + if h.FileSystem == nil { + err = HTTPErrorf(http.StatusInternalServerError, "webdav: no filesystem available") + } else { + switch r.Method { + case http.MethodOptions: + err = h.handleOptions(w, r) + case http.MethodGet, http.MethodHead: + err = h.handleGetHead(w, r) + case "PROPFIND": + err = h.handlePropfind(w, r) + default: + err = HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method") + } + } + + if err != nil { + code := http.StatusInternalServerError + if httpErr, ok := err.(*HTTPError); ok { + code = httpErr.Code + } + http.Error(w, err.Error(), code) + } +} + +func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) error { + w.Header().Add("Allow", "OPTIONS, GET, HEAD") + w.Header().Add("DAV", "1") + w.WriteHeader(http.StatusNoContent) + return nil +} + +func (h *Handler) handleGetHead(w http.ResponseWriter, r *http.Request) error { + f, err := h.FileSystem.Open(r.URL.Path) + if err != nil { + return err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + + http.ServeContent(w, r, r.URL.Path, fi.ModTime(), f) + return nil +} + +func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error { + t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if t != "application/xml" && t != "text/xml" { + return HTTPErrorf(http.StatusBadRequest, "webdav: expected application/xml PROPFIND request") + } + + var propfind internal.Propfind + if err := xml.NewDecoder(r.Body).Decode(&propfind); err != nil { + return &HTTPError{http.StatusBadRequest, err} + } + + depth := internal.DepthInfinity + if s := r.Header.Get("Depth"); s != "" { + var err error + depth, err = internal.ParseDepth(s) + if err != nil { + return &HTTPError{http.StatusBadRequest, err} + } + } + + f, err := h.FileSystem.Open(r.URL.Path) + if err != nil { + return err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + + if depth != internal.DepthZero { + depth = internal.DepthZero // TODO + } + + resp, err := h.propfindFile(&propfind, r.URL.Path, fi) + if err != nil { + return err + } + + ms := internal.NewMultistatus(*resp) + + w.Header().Add("Content-Type", "text/xml; charset=\"utf-8\"") + w.WriteHeader(http.StatusMultiStatus) + w.Write([]byte(xml.Header)) + return xml.NewEncoder(w).Encode(&ms) +} + +func (h *Handler) propfindFile(propfind *internal.Propfind, name string, fi os.FileInfo) (*internal.Response, error) { + resp := internal.NewOKResponse(name) + + if prop := propfind.Prop; prop != nil { + for _, xmlName := range prop.XMLNames() { + emptyVal := internal.NewRawXMLElement(xmlName, nil, nil) + + var code int + var val interface{} = emptyVal + f, ok := liveProps[xmlName] + if ok { + if v, err := f(h, name, fi); err != nil { + code = http.StatusInternalServerError // TODO: better error handling + } else { + code = http.StatusOK + val = v + } + } else { + code = http.StatusNotFound + } + + if err := resp.EncodeProp(code, val); err != nil { + return nil, err + } + } + } + + return resp, nil +} + +type PropfindFunc func(h *Handler, name string, fi os.FileInfo) (interface{}, error) + +var liveProps = map[xml.Name]PropfindFunc{ + {"DAV:", "resourcetype"}: func(h *Handler, name string, fi os.FileInfo) (interface{}, error) { + var types []xml.Name + if fi.IsDir() { + types = append(types, internal.CollectionName) + } + return internal.NewResourceType(types...), nil + }, +}