touka/webdav/webdav.go
google-labs-jules[bot] 26cbf45074 feat: add native WebDAV submodule with usability helpers and fixes
This commit introduces a new, high-performance, and extensible WebDAV submodule, implemented natively without external dependencies. It also adds a high-level API to simplify common use cases and incorporates numerous fixes based on detailed code reviews.

Features:
- A core WebDAV handler supporting `PROPFIND`, `MKCOL`, `GET`, `PUT`, `DELETE`, `COPY`, `MOVE`, `LOCK`, `UNLOCK`.
- An extensible design with `FileSystem` and `LockSystem` interfaces.
- `MemFS`: A robust, tree-based in-memory filesystem for testing.
- `OSFS`: A secure OS-based filesystem with protection against path traversal and symlink attacks.
- `MemLock`: An in-memory locking system with graceful shutdown to prevent resource leaks.
- A high-level API (`webdav.Serve`, `webdav.Register`) for ease of use.

Fixes & Improvements:
- Security: Patched directory traversal and symlink vulnerabilities. Ensured secure lock token generation.
- RFC Compliance: Corrected status codes for `COPY`/`MOVE` (201 vs 204), `DELETE` on non-empty collections (409), and `Timeout` header parsing.
- Performance: Implemented `sync.Pool` for object reuse and `sync/atomic` for file size management to reduce GC pressure.
- Robustness: Fixed numerous bugs related to path handling, resource cleanup (goroutine leaks), and header parsing.

Integration:
- The Touka framework's core has been updated to recognize all necessary WebDAV methods.
- Includes comprehensive unit tests and a working example.
2025-12-11 07:33:34 +00:00

751 lines
20 KiB
Go

// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright 2024 WJQSERVER. All rights reserved.
// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization.
package webdav
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
"sync"
"time"
"github.com/infinite-iroha/touka"
)
// FileSystem defines the interface for a file system to be served by the WebDAV handler.
// It provides methods for file and directory manipulation and information retrieval,
// abstracting the underlying storage from the WebDAV protocol logic.
type FileSystem interface {
Mkdir(ctx context.Context, name string, perm os.FileMode) error
OpenFile(c *touka.Context, name string, flag int, perm os.FileMode) (File, error)
RemoveAll(ctx context.Context, name string) error
Rename(ctx context.Context, oldName, newName string) error
Stat(ctx context.Context, name string) (ObjectInfo, error)
}
// File defines the interface for a file-like object in the FileSystem.
// It embeds standard io interfaces for reading, writing, seeking, and closing,
// and adds methods for directory listing and metadata retrieval.
type File interface {
io.Closer
io.Reader
io.Seeker
io.Writer
Readdir(count int) ([]ObjectInfo, error)
Stat() (ObjectInfo, error)
}
// ObjectInfo provides a common interface for file and directory metadata.
// It is designed to be compatible with os.FileInfo to allow for easy integration
// with standard library functions, while providing an abstraction layer.
type ObjectInfo interface {
Name() string
Size() int64
Mode() os.FileMode
ModTime() time.Time
IsDir() bool
Sys() interface{} // Underlying data source (can be nil).
}
// Propfind represents the XML structure of a PROPFIND request body.
// It allows clients to request all properties (`Allprop`), a specific set of
// properties (`Prop`), or just property names (`Propname`).
type Propfind struct {
XMLName xml.Name `xml:"DAV: propfind"`
Allprop *struct{} `xml:"DAV: allprop"`
Prop *Prop `xml:"DAV: prop"`
Propname *struct{} `xml:"DAV: propname"`
}
// Prop represents a container for specific properties requested or returned
// in PROPFIND and PROPPATCH methods. Each field corresponds to a DAV property.
type Prop struct {
XMLName xml.Name `xml:"DAV: prop"`
GetContentLength *string `xml:"DAV: getcontentlength,omitempty"`
GetLastModified *string `xml:"DAV: getlastmodified,omitempty"`
GetContentType *string `xml:"DAV: getcontenttype,omitempty"`
ResourceType *ResourceType `xml:"DAV: resourcetype,omitempty"`
CreationDate *string `xml:"DAV: creationdate,omitempty"`
DisplayName *string `xml:"DAV: displayname,omitempty"`
SupportedLock *SupportedLock `xml:"DAV: supportedlock,omitempty"`
LockDiscovery *LockDiscovery `xml:"DAV: lockdiscovery,omitempty"`
}
// LockDiscovery contains information about the active locks on a resource.
type LockDiscovery struct {
XMLName xml.Name `xml:"DAV: lockdiscovery"`
ActiveLock []ActiveLock `xml:"DAV: activelock"`
}
// ActiveLock describes an active lock on a resource.
type ActiveLock struct {
XMLName xml.Name `xml:"DAV: activelock"`
LockType LockType `xml:"DAV: locktype"`
LockScope LockScope `xml:"DAV: lockscope"`
Depth string `xml:"DAV: depth"`
Owner Owner `xml:"DAV: owner"`
Timeout string `xml:"DAV: timeout"`
LockToken *LockToken `xml:"DAV: locktoken,omitempty"`
}
// LockToken represents a lock token.
type LockToken struct {
XMLName xml.Name `xml:"DAV: locktoken"`
Href string `xml:"DAV: href"`
}
// ResourceType indicates the nature of a resource, typically whether it is
// a collection (directory) or a standard resource.
type ResourceType struct {
XMLName xml.Name `xml:"DAV: resourcetype"`
Collection *struct{} `xml:"DAV: collection,omitempty"`
}
// SupportedLock defines the types of locks supported by a resource.
type SupportedLock struct {
XMLName xml.Name `xml:"DAV: supportedlock"`
LockEntry []LockEntry `xml:"DAV: lockentry"`
}
// LockEntry describes a single type of lock that is supported.
type LockEntry struct {
XMLName xml.Name `xml:"DAV: lockentry"`
LockScope LockScope `xml:"DAV: lockscope"`
LockType LockType `xml:"DAV: locktype"`
}
// LockScope specifies whether a lock is exclusive or shared.
type LockScope struct {
XMLName xml.Name `xml:"DAV: lockscope"`
Exclusive *struct{} `xml:"DAV: exclusive,omitempty"`
Shared *struct{} `xml:"DAV: shared,omitempty"`
}
// LockType indicates the type of lock, typically a write lock.
type LockType struct {
XMLName xml.Name `xml:"DAV: locktype"`
Write *struct{} `xml:"DAV: write,omitempty"`
}
// Multistatus is the root element for responses to PROPFIND and PROPPATCH
// requests, containing multiple individual responses for different resources.
type Multistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []*Response `xml:"DAV: response"`
}
// Response represents the status and properties of a single resource within
// a Multistatus response.
type Response struct {
XMLName xml.Name `xml:"DAV: response"`
Href []string `xml:"DAV: href"`
Propstats []Propstat `xml:"DAV: propstat"`
}
var multistatusPool = sync.Pool{
New: func() interface{} {
return &Multistatus{
Responses: make([]*Response, 0),
}
},
}
// Propstat groups properties with their corresponding HTTP status in a
// single response, indicating success or failure for those properties.
type Propstat struct {
XMLName xml.Name `xml:"DAV: propstat"`
Prop Prop `xml:"DAV: prop"`
Status string `xml:"DAV: status"`
}
// LockSystem is the interface for a lock manager.
type LockSystem interface {
// Create creates a new lock.
Create(ctx context.Context, path string, lockInfo LockInfo) (string, error)
// Refresh refreshes an existing lock.
Refresh(ctx context.Context, token string, timeout time.Duration) error
// Unlock removes a lock.
Unlock(ctx context.Context, token string) error
}
// Handler handles WebDAV requests.
type Handler struct {
// Prefix is the URL prefix that the handler is mounted on.
Prefix string
// FileSystem is the file system that is served.
FileSystem FileSystem
// LockSystem is the lock system. If nil, locking is disabled.
LockSystem LockSystem
// Logger is the logger to use. If nil, logging is disabled.
Logger Logger
}
// LockInfo contains information about a lock.
type LockInfo struct {
XMLName xml.Name `xml:"DAV: lockinfo"`
LockScope LockScope `xml:"DAV: lockscope"`
LockType LockType `xml:"DAV: locktype"`
Owner Owner `xml:"DAV: owner"`
Timeout time.Duration
}
// Owner represents the owner of a lock.
type Owner struct {
XMLName xml.Name `xml:"DAV: owner"`
Href string `xml:"DAV: href"`
}
// Logger is a simple logging interface.
type Logger interface {
Printf(format string, v ...interface{})
}
// NewHandler returns a new Handler.
func NewHandler(prefix string, fs FileSystem, ls LockSystem, logger Logger) *Handler {
return &Handler{
Prefix: prefix,
FileSystem: fs,
LockSystem: ls,
Logger: logger,
}
}
// ServeTouka handles a Touka request.
func (h *Handler) ServeTouka(c *touka.Context) {
path := h.stripPrefix(c.Request.URL.Path)
c.Set("webdav_path", path)
switch c.Request.Method {
case "OPTIONS":
h.handleOptions(c)
case "GET", "HEAD":
h.handleGetHead(c)
case "DELETE":
h.handleDelete(c)
case "PUT":
h.handlePut(c)
case "MKCOL":
h.handleMkcol(c)
case "COPY":
h.handleCopy(c)
case "MOVE":
h.handleMove(c)
case "PROPFIND":
h.handlePropfind(c)
case "PROPPATCH":
h.handleProppatch(c)
case "LOCK":
h.handleLock(c)
case "UNLOCK":
h.handleUnlock(c)
default:
c.Status(http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleOptions(c *touka.Context) {
allow := "OPTIONS, GET, HEAD, DELETE, PUT, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH"
dav := "1"
if h.LockSystem != nil {
allow += ", LOCK, UNLOCK"
dav += ", 2"
}
c.SetHeader("Allow", allow)
c.SetHeader("DAV", dav)
c.Status(http.StatusOK)
}
func (h *Handler) handleGetHead(c *touka.Context) {
path, _ := c.Get("webdav_path")
file, err := h.FileSystem.OpenFile(c, path.(string), os.O_RDONLY, 0)
if err != nil {
if os.IsNotExist(err) {
c.Status(http.StatusNotFound)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
if info.IsDir() {
c.Status(http.StatusForbidden)
return
}
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
}
func (h *Handler) handleDelete(c *touka.Context) {
path, _ := c.Get("webdav_path")
pathStr := path.(string)
info, err := h.FileSystem.Stat(c.Context(), pathStr)
if err != nil {
if os.IsNotExist(err) {
c.Status(http.StatusNotFound)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
if info.IsDir() {
file, err := h.FileSystem.OpenFile(c, pathStr, os.O_RDONLY, 0)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
defer file.Close()
// Check if the directory has any children. Readdir(1) is enough.
children, err := file.Readdir(1)
if err != nil && err != io.EOF {
c.Status(http.StatusInternalServerError)
return
}
if len(children) > 0 {
c.Status(http.StatusConflict) // 409 Conflict for non-empty collection
return
}
}
if err := h.FileSystem.RemoveAll(c.Context(), pathStr); err != nil {
if os.IsNotExist(err) {
c.Status(http.StatusNotFound)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) handlePut(c *touka.Context) {
path, _ := c.Get("webdav_path")
file, err := h.FileSystem.OpenFile(c, path.(string), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
defer file.Close()
if _, err := io.Copy(file, c.Request.Body); err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusCreated)
}
func (h *Handler) handleMkcol(c *touka.Context) {
path, _ := c.Get("webdav_path")
if err := h.FileSystem.Mkdir(c.Context(), path.(string), 0755); err != nil {
if os.IsExist(err) {
c.Status(http.StatusMethodNotAllowed)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusCreated)
}
func (h *Handler) handleCopy(c *touka.Context) {
srcPath, _ := c.Get("webdav_path")
destPath := c.GetReqHeader("Destination")
if destPath == "" {
c.Status(http.StatusBadRequest)
return
}
// A more complete implementation would parse the full URL.
// For now, we assume the destination is a simple path.
destURL, err := url.Parse(destPath)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
destPath = h.stripPrefix(destURL.Path)
overwrite := c.GetReqHeader("Overwrite")
if overwrite == "" {
overwrite = "T" // Default is to overwrite
}
// Check for existence before the operation to determine status code later.
_, err = h.FileSystem.Stat(c.Context(), destPath)
existed := err == nil
if overwrite == "F" && existed {
c.Status(http.StatusPreconditionFailed)
return
}
if err := h.copy(c, srcPath.(string), destPath); err != nil {
c.Status(http.StatusInternalServerError)
return
}
if existed {
c.Status(http.StatusNoContent)
} else {
c.Status(http.StatusCreated)
}
}
func (h *Handler) handleMove(c *touka.Context) {
srcPath, _ := c.Get("webdav_path")
destPath := c.GetReqHeader("Destination")
if destPath == "" {
c.Status(http.StatusBadRequest)
return
}
destURL, err := url.Parse(destPath)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
destPath = h.stripPrefix(destURL.Path)
overwrite := c.GetReqHeader("Overwrite")
if overwrite == "" {
overwrite = "T" // Default is to overwrite
}
// Check for existence before the operation to determine status code later.
_, err = h.FileSystem.Stat(c.Context(), destPath)
existed := err == nil
if overwrite == "F" && existed {
c.Status(http.StatusPreconditionFailed)
return
}
if err := h.FileSystem.Rename(c.Context(), srcPath.(string), destPath); err != nil {
c.Status(http.StatusInternalServerError)
return
}
if existed {
c.Status(http.StatusNoContent)
} else {
c.Status(http.StatusCreated)
}
}
func (h *Handler) copy(c *touka.Context, src, dest string) error {
info, err := h.FileSystem.Stat(c.Context(), src)
if err != nil {
return err
}
if info.IsDir() {
if err := h.FileSystem.Mkdir(c.Context(), dest, info.Mode()); err != nil {
return err
}
srcFile, err := h.FileSystem.OpenFile(c, src, os.O_RDONLY, 0)
if err != nil {
return err
}
defer srcFile.Close()
children, err := srcFile.Readdir(0)
if err != nil {
return err
}
for _, child := range children {
if err := h.copy(c, path.Join(src, child.Name()), path.Join(dest, child.Name())); err != nil {
return err
}
}
return nil
}
srcFile, err := h.FileSystem.OpenFile(c, src, os.O_RDONLY, 0)
if err != nil {
return err
}
defer srcFile.Close()
destFile, err := h.FileSystem.OpenFile(c, dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, srcFile)
return err
}
func (h *Handler) handlePropfind(c *touka.Context) {
requestPath, _ := c.Get("webdav_path")
info, err := h.FileSystem.Stat(c.Context(), requestPath.(string))
if err != nil {
if os.IsNotExist(err) {
c.Status(http.StatusNotFound)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
var propfind Propfind
if c.Request.ContentLength != 0 {
if err := xml.NewDecoder(c.Request.Body).Decode(&propfind); err != nil {
c.Status(http.StatusBadRequest)
return
}
}
ms := multistatusPool.Get().(*Multistatus)
defer func() {
ms.Responses = ms.Responses[:0]
multistatusPool.Put(ms)
}()
depth := c.GetReqHeader("Depth")
if depth == "" {
depth = "infinity"
}
ms.Responses = append(ms.Responses, h.createPropfindResponse(requestPath.(string), info, propfind))
if info.IsDir() && depth != "0" {
var walk func(string, int) error
walk = func(p string, maxDepth int) error {
if maxDepth == 0 {
return nil
}
file, err := h.FileSystem.OpenFile(c, p, os.O_RDONLY, 0)
if err != nil {
return err
}
defer file.Close()
children, err := file.Readdir(0)
if err != nil {
return err
}
for _, child := range children {
childPath := path.Join(p, child.Name())
childInfo, err := h.FileSystem.Stat(c.Context(), childPath)
if err != nil {
if h.Logger != nil {
h.Logger.Printf("PROPFIND walk: failed to stat child %s: %v", childPath, err)
}
continue
}
ms.Responses = append(ms.Responses, h.createPropfindResponse(childPath, childInfo, propfind))
if childInfo.IsDir() {
if err := walk(childPath, maxDepth-1); err != nil {
return err
}
}
}
return nil
}
walkDepth := -1
if depth == "1" {
walkDepth = 1
}
if err := walk(requestPath.(string), walkDepth); err != nil {
if h.Logger != nil {
h.Logger.Printf("Error during PROPFIND walk: %v", err)
}
c.Status(http.StatusInternalServerError)
return
}
}
c.Writer.Header().Set("Content-Type", "application/xml; charset=utf-8")
c.Status(http.StatusMultiStatus)
if err := xml.NewEncoder(c.Writer).Encode(ms); err != nil {
h.Logger.Printf("Error encoding propfind response: %v", err)
}
}
func (h *Handler) createPropfindResponse(p string, info ObjectInfo, propfind Propfind) *Response {
fullPath := path.Join(h.Prefix, p)
resp := &Response{
Href: []string{fullPath},
Propstats: make([]Propstat, 0),
}
prop := Prop{}
if propfind.Allprop != nil {
prop.GetContentLength = new(string)
*prop.GetContentLength = fmt.Sprintf("%d", info.Size())
prop.GetLastModified = new(string)
*prop.GetLastModified = info.ModTime().Format(http.TimeFormat)
prop.ResourceType = &ResourceType{}
if info.IsDir() {
prop.ResourceType.Collection = &struct{}{}
}
} else if propfind.Prop != nil {
if propfind.Prop.GetContentLength != nil {
prop.GetContentLength = new(string)
*prop.GetContentLength = fmt.Sprintf("%d", info.Size())
}
if propfind.Prop.GetLastModified != nil {
prop.GetLastModified = new(string)
*prop.GetLastModified = info.ModTime().Format(http.TimeFormat)
}
if propfind.Prop.ResourceType != nil {
prop.ResourceType = &ResourceType{}
if info.IsDir() {
prop.ResourceType.Collection = &struct{}{}
}
}
}
resp.Propstats = append(resp.Propstats, Propstat{
Prop: prop,
Status: "HTTP/1.1 200 OK",
})
return resp
}
func (h *Handler) handleProppatch(c *touka.Context) {
c.Status(http.StatusNotImplemented)
}
func (h *Handler) stripPrefix(p string) string {
return strings.TrimPrefix(strings.TrimPrefix(p, h.Prefix), "/")
}
func (h *Handler) handleLock(c *touka.Context) {
if h.LockSystem == nil {
c.Status(http.StatusMethodNotAllowed)
return
}
path, _ := c.Get("webdav_path")
tokenHeader := c.GetReqHeader("If")
var token string
if tokenHeader != "" {
// Basic parsing for <opaquelocktoken:c2134f...>
if strings.HasPrefix(tokenHeader, "(<") && strings.HasSuffix(tokenHeader, ">)") {
token = strings.TrimPrefix(tokenHeader, "(<")
token = strings.TrimSuffix(token, ">)")
}
}
// Refresh lock
if token != "" {
timeoutStr := c.GetReqHeader("Timeout")
timeout, err := parseTimeout(timeoutStr)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
if err := h.LockSystem.Refresh(c.Context(), token, timeout); err != nil {
c.Status(http.StatusPreconditionFailed)
return
}
} else {
// Create lock
var lockInfo LockInfo
if err := xml.NewDecoder(c.Request.Body).Decode(&lockInfo); err != nil {
c.Status(http.StatusBadRequest)
return
}
timeoutStr := c.GetReqHeader("Timeout")
timeout, err := parseTimeout(timeoutStr)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
lockInfo.Timeout = timeout
token, err = h.LockSystem.Create(c.Context(), path.(string), lockInfo)
if err != nil {
c.Status(http.StatusConflict)
return
}
}
prop := Prop{
LockDiscovery: &LockDiscovery{
ActiveLock: []ActiveLock{
{
LockToken: &LockToken{Href: token},
},
},
},
}
c.Writer.Header().Set("Content-Type", "application/xml; charset=utf-8")
c.Writer.Header().Set("Lock-Token", token)
c.Status(http.StatusOK)
xml.NewEncoder(c.Writer).Encode(prop)
}
func parseTimeout(timeoutStr string) (time.Duration, error) {
if timeoutStr == "" || strings.ToLower(timeoutStr) == "infinite" {
// A long timeout, as per RFC 4918.
return 10 * time.Minute, nil
}
// "Second-123"
parts := strings.Split(timeoutStr, "-")
if len(parts) == 2 && strings.ToLower(parts[0]) == "second" {
seconds, err := time.ParseDuration(parts[1] + "s")
if err == nil {
return seconds, nil
}
}
return 0, os.ErrInvalid
}
func (h *Handler) handleUnlock(c *touka.Context) {
if h.LockSystem == nil {
c.Status(http.StatusMethodNotAllowed)
return
}
tokenHeader := c.GetReqHeader("Lock-Token")
if tokenHeader == "" {
c.Status(http.StatusBadRequest)
return
}
// Basic parsing for <urn:uuid:f81d4fae...>
token := strings.TrimPrefix(tokenHeader, "<")
token = strings.TrimSuffix(token, ">")
if err := h.LockSystem.Unlock(c.Context(), token); err != nil {
c.Status(http.StatusConflict)
return
}
c.Status(http.StatusNoContent)
}