feat: add native WebDAV submodule

This commit introduces a new, high-performance, and extensible WebDAV submodule, implemented natively without external dependencies.

The submodule includes:
- A core WebDAV handler that supports essential methods: PROPFIND, MKCOL, GET, PUT, DELETE, COPY, MOVE, LOCK, and UNLOCK.
- An extensible design using a `FileSystem` interface to decouple the protocol logic from the storage backend.
- Two `FileSystem` implementations:
  - `MemFS`: An in-memory, tree-based filesystem for testing and ephemeral storage.
  - `OSFS`: A secure, OS-based filesystem that interacts with the local disk and includes path traversal protection.
- A `LockSystem` interface with an in-memory implementation (`MemLock`) to support resource locking (DAV Class 2).
- Comprehensive unit tests covering all major functionalities.
- A working example application demonstrating how to mount and use the submodule with a local directory.

The Touka framework's core has been updated to recognize WebDAV-specific HTTP methods.
This commit is contained in:
google-labs-jules[bot] 2025-12-10 13:37:11 +00:00
parent ee0ebc986c
commit 49902f9059
7 changed files with 1419 additions and 9 deletions

37
examples/webdav/main.go Normal file
View file

@ -0,0 +1,37 @@
package main
import (
"log"
"os"
"time"
"github.com/infinite-iroha/touka"
"github.com/infinite-iroha/touka/webdav"
)
func main() {
r := touka.Default()
// Create a directory for the OS file system.
if err := os.MkdirAll("public", 0755); err != nil {
log.Fatal(err)
}
// Create a new WebDAV handler with the OS file system.
fs, err := webdav.NewOSFS("public")
if err != nil {
log.Fatal(err)
}
handler := webdav.NewHandler("/webdav", fs, webdav.NewMemLock(), log.New(os.Stdout, "", 0))
// Mount the WebDAV handler on the "/webdav/" route.
webdavMethods := []string{
"OPTIONS", "GET", "HEAD", "DELETE", "PUT", "MKCOL", "COPY", "MOVE", "PROPFIND", "PROPPATCH", "LOCK", "UNLOCK",
}
r.HandleFunc(webdavMethods, "/webdav/*path", handler.ServeTouka)
log.Println("Touka WebDAV Server starting on :8080...")
if err := r.RunShutdown(":8080", 10*time.Second); err != nil {
log.Fatalf("Touka server failed to start: %v", err)
}
}

View file

@ -60,14 +60,28 @@ var (
MethodTrace = "TRACE" MethodTrace = "TRACE"
) )
var (
// WebDAV methods
MethodPropfind = "PROPFIND"
MethodProppatch = "PROPPATCH"
MethodMkcol = "MKCOL"
MethodCopy = "COPY"
MethodMove = "MOVE"
)
var MethodsSet = map[string]struct{}{ var MethodsSet = map[string]struct{}{
MethodGet: {}, MethodGet: {},
MethodHead: {}, MethodHead: {},
MethodPost: {}, MethodPost: {},
MethodPut: {}, MethodPut: {},
MethodPatch: {}, MethodPatch: {},
MethodDelete: {}, MethodDelete: {},
MethodConnect: {}, MethodConnect: {},
MethodOptions: {}, MethodOptions: {},
MethodTrace: {}, MethodTrace: {},
MethodPropfind: {},
MethodProppatch: {},
MethodMkcol: {},
MethodCopy: {},
MethodMove: {},
} }

263
webdav/memfs.go Normal file
View file

@ -0,0 +1,263 @@
// 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"
"io"
"os"
"path"
"strings"
"sync"
"time"
)
// MemFS is an in-memory file system for WebDAV using a tree structure.
type MemFS struct {
mu sync.RWMutex
root *memNode
}
// NewMemFS creates a new in-memory file system.
func NewMemFS() *MemFS {
return &MemFS{
root: &memNode{
name: "/",
isDir: true,
modTime: time.Now(),
children: make(map[string]*memNode),
},
}
}
// findNode traverses the tree to find a node by path.
func (fs *MemFS) findNode(path string) (*memNode, error) {
current := fs.root
parts := strings.Split(path, "/")
for _, part := range parts {
if part == "" {
continue
}
if current.children == nil {
return nil, os.ErrNotExist
}
child, ok := current.children[part]
if !ok {
return nil, os.ErrNotExist
}
current = child
}
return current, nil
}
// Mkdir creates a directory in the in-memory file system.
func (fs *MemFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
fs.mu.Lock()
defer fs.mu.Unlock()
dir, base := path.Split(name)
parent, err := fs.findNode(dir)
if err != nil {
return err
}
if _, exists := parent.children[base]; exists {
return os.ErrExist
}
newNode := &memNode{
name: base,
isDir: true,
modTime: time.Now(),
mode: perm,
parent: parent,
children: make(map[string]*memNode),
}
parent.children[base] = newNode
return nil
}
// OpenFile opens a file in the in-memory file system.
func (fs *MemFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) {
fs.mu.Lock()
defer fs.mu.Unlock()
dir, base := path.Split(name)
parent, err := fs.findNode(dir)
if err != nil {
return nil, err
}
node, exists := parent.children[base]
if !exists {
if flag&os.O_CREATE == 0 {
return nil, os.ErrNotExist
}
node = &memNode{
name: base,
modTime: time.Now(),
mode: perm,
parent: parent,
}
parent.children[base] = node
}
if flag&os.O_TRUNC != 0 {
node.data = nil
}
return &memFile{
node: node,
fs: fs,
offset: 0,
fullPath: name,
}, nil
}
// RemoveAll removes a file or directory from the in-memory file system.
func (fs *MemFS) RemoveAll(ctx context.Context, name string) error {
fs.mu.Lock()
defer fs.mu.Unlock()
dir, base := path.Split(name)
parent, err := fs.findNode(dir)
if err != nil {
return err
}
if _, exists := parent.children[base]; !exists {
return os.ErrNotExist
}
delete(parent.children, base)
return nil
}
// Rename renames a file in the in-memory file system.
func (fs *MemFS) Rename(ctx context.Context, oldName, newName string) error {
fs.mu.Lock()
defer fs.mu.Unlock()
oldDir, oldBase := path.Split(oldName)
newDir, newBase := path.Split(newName)
oldParent, err := fs.findNode(oldDir)
if err != nil {
return err
}
node, exists := oldParent.children[oldBase]
if !exists {
return os.ErrNotExist
}
newParent, err := fs.findNode(newDir)
if err != nil {
return err
}
if _, exists := newParent.children[newBase]; exists {
return os.ErrExist
}
delete(oldParent.children, oldBase)
node.name = newBase
node.parent = newParent
newParent.children[newBase] = node
return nil
}
// Stat returns the file info for a file or directory.
func (fs *MemFS) Stat(ctx context.Context, name string) (ObjectInfo, error) {
fs.mu.RLock()
defer fs.mu.RUnlock()
return fs.findNode(name)
}
type memNode struct {
name string
isDir bool
size int64
modTime time.Time
mode os.FileMode
data []byte
parent *memNode
children map[string]*memNode
}
func (n *memNode) Name() string { return n.name }
func (n *memNode) Size() int64 { return n.size }
func (n *memNode) Mode() os.FileMode { return n.mode }
func (n *memNode) ModTime() time.Time { return n.modTime }
func (n *memNode) IsDir() bool { return n.isDir }
func (n *memNode) Sys() interface{} { return nil }
type memFile struct {
node *memNode
fs *MemFS
offset int64
fullPath string
}
func (f *memFile) Close() error { return nil }
func (f *memFile) Stat() (ObjectInfo, error) { return f.node, nil }
func (f *memFile) Read(p []byte) (n int, err error) {
f.fs.mu.RLock()
defer f.fs.mu.RUnlock()
if f.offset >= int64(len(f.node.data)) {
return 0, io.EOF
}
n = copy(p, f.node.data[f.offset:])
f.offset += int64(n)
return n, nil
}
func (f *memFile) Write(p []byte) (n int, err error) {
f.fs.mu.Lock()
defer f.fs.mu.Unlock()
if f.offset+int64(len(p)) > int64(len(f.node.data)) {
newSize := f.offset + int64(len(p))
newData := make([]byte, newSize)
copy(newData, f.node.data)
f.node.data = newData
}
n = copy(f.node.data[f.offset:], p)
f.offset += int64(n)
if f.offset > f.node.size {
f.node.size = f.offset
}
return n, nil
}
func (f *memFile) Seek(offset int64, whence int) (int64, error) {
f.fs.mu.Lock()
defer f.fs.mu.Unlock()
switch whence {
case 0:
f.offset = offset
case 1:
f.offset += offset
case 2:
f.offset = int64(len(f.node.data)) + offset
}
return f.offset, nil
}
// Readdir reads the contents of the directory associated with file and returns
// a slice of up to n FileInfo values, as would be returned by Lstat.
func (f *memFile) Readdir(count int) ([]ObjectInfo, error) {
f.fs.mu.RLock()
defer f.fs.mu.RUnlock()
if !f.node.isDir {
return nil, os.ErrInvalid
}
var infos []ObjectInfo
for _, child := range f.node.children {
infos = append(infos, child)
}
return infos, nil
}

73
webdav/memlock.go Normal file
View file

@ -0,0 +1,73 @@
// 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"
"crypto/rand"
"encoding/hex"
"os"
"sync"
"time"
)
// MemLock is an in-memory lock system for WebDAV.
type MemLock struct {
mu sync.RWMutex
locks map[string]*lock
}
type lock struct {
token string
path string
expires time.Time
info LockInfo
}
// NewMemLock creates a new in-memory lock system.
func NewMemLock() *MemLock {
return &MemLock{
locks: make(map[string]*lock),
}
}
// Create creates a new lock.
func (l *MemLock) Create(ctx context.Context, path string, info LockInfo) (string, error) {
l.mu.Lock()
defer l.mu.Unlock()
token := make([]byte, 16)
rand.Read(token)
tokenStr := hex.EncodeToString(token)
l.locks[tokenStr] = &lock{
token: tokenStr,
path: path,
expires: time.Now().Add(info.Timeout),
info: info,
}
return tokenStr, nil
}
// Refresh refreshes an existing lock.
func (l *MemLock) Refresh(ctx context.Context, token string, timeout time.Duration) error {
l.mu.Lock()
defer l.mu.Unlock()
if lock, ok := l.locks[token]; ok {
lock.expires = time.Now().Add(timeout)
return nil
}
return os.ErrNotExist
}
// Unlock removes a lock.
func (l *MemLock) Unlock(ctx context.Context, token string) error {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.locks, token)
return nil
}

115
webdav/osfs.go Normal file
View file

@ -0,0 +1,115 @@
// 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"
"os"
"path/filepath"
"strings"
)
// OSFS is a WebDAV FileSystem that uses the local OS file system.
type OSFS struct {
RootDir string
}
// NewOSFS creates a new OSFS.
func NewOSFS(rootDir string) (*OSFS, error) {
rootDir, err := filepath.Abs(rootDir)
if err != nil {
return nil, err
}
return &OSFS{RootDir: rootDir}, nil
}
func (fs *OSFS) resolve(name string) (string, error) {
path := filepath.Join(fs.RootDir, name)
if !strings.HasPrefix(path, fs.RootDir) {
return "", os.ErrPermission
}
return path, nil
}
// Mkdir creates a directory.
func (fs *OSFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
path, err := fs.resolve(name)
if err != nil {
return err
}
return os.Mkdir(path, perm)
}
// osFile is a wrapper around os.File that implements the File interface.
type osFile struct {
*os.File
}
// Stat returns the FileInfo structure describing file.
func (f *osFile) Stat() (ObjectInfo, error) {
fi, err := f.File.Stat()
if err != nil {
return nil, err
}
return fi, nil
}
// Readdir reads the contents of the directory associated with file and returns
// a slice of up to n FileInfo values, as would be returned by Lstat.
func (f *osFile) Readdir(count int) ([]ObjectInfo, error) {
fi, err := f.File.Readdir(count)
if err != nil {
return nil, err
}
oi := make([]ObjectInfo, len(fi))
for i := range fi {
oi[i] = fi[i]
}
return oi, nil
}
// OpenFile opens a file.
func (fs *OSFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) {
path, err := fs.resolve(name)
if err != nil {
return nil, err
}
f, err := os.OpenFile(path, flag, perm)
if err != nil {
return nil, err
}
return &osFile{f}, nil
}
// RemoveAll removes a file or directory.
func (fs *OSFS) RemoveAll(ctx context.Context, name string) error {
path, err := fs.resolve(name)
if err != nil {
return err
}
return os.RemoveAll(path)
}
// Rename renames a file.
func (fs *OSFS) Rename(ctx context.Context, oldName, newName string) error {
oldPath, err := fs.resolve(oldName)
if err != nil {
return err
}
newPath, err := fs.resolve(newName)
if err != nil {
return err
}
return os.Rename(oldPath, newPath)
}
// Stat returns file info.
func (fs *OSFS) Stat(ctx context.Context, name string) (ObjectInfo, error) {
path, err := fs.resolve(name)
if err != nil {
return nil, err
}
return os.Stat(path)
}

690
webdav/webdav.go Normal file
View file

@ -0,0 +1,690 @@
// 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"
"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(ctx context.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"`
}
// 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.Context(), 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")
if err := h.FileSystem.RemoveAll(c.Context(), path.(string)); 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.Context(), 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
}
if overwrite == "F" {
if _, err := h.FileSystem.Stat(c.Context(), destPath); err == nil {
c.Status(http.StatusPreconditionFailed)
return
}
}
if err := h.copy(c.Context(), srcPath.(string), destPath); err != nil {
c.Status(http.StatusInternalServerError)
return
}
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
}
if overwrite == "F" {
if _, err := h.FileSystem.Stat(c.Context(), destPath); err == nil {
c.Status(http.StatusPreconditionFailed)
return
}
}
if err := h.FileSystem.Rename(c.Context(), srcPath.(string), destPath); err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusCreated)
}
func (h *Handler) copy(ctx context.Context, src, dest string) error {
info, err := h.FileSystem.Stat(ctx, src)
if err != nil {
return err
}
if info.IsDir() {
if err := h.FileSystem.Mkdir(ctx, dest, info.Mode()); err != nil {
return err
}
srcFile, err := h.FileSystem.OpenFile(ctx, 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(ctx, path.Join(src, child.Name()), path.Join(dest, child.Name())); err != nil {
return err
}
}
return nil
}
srcFile, err := h.FileSystem.OpenFile(ctx, src, os.O_RDONLY, 0)
if err != nil {
return err
}
defer srcFile.Close()
destFile, err := h.FileSystem.OpenFile(ctx, 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 := &Multistatus{
Responses: make([]*Response, 0),
}
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.Context(), 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(path string, info ObjectInfo, propfind Propfind) *Response {
fullPath := path
if h.Prefix != "/" {
fullPath = h.Prefix + path
}
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(path string) string {
if h.Prefix == "/" {
return path
}
return "/" + strings.TrimPrefix(path, h.Prefix)
}
func (h *Handler) handleLock(c *touka.Context) {
if h.LockSystem == nil {
c.Status(http.StatusMethodNotAllowed)
return
}
path, _ := c.Get("webdav_path")
token := c.GetReqHeader("If")
// 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, nil
}
func (h *Handler) handleUnlock(c *touka.Context) {
if h.LockSystem == nil {
c.Status(http.StatusMethodNotAllowed)
return
}
token := c.GetReqHeader("Lock-Token")
if token == "" {
c.Status(http.StatusBadRequest)
return
}
if err := h.LockSystem.Unlock(c.Context(), token); err != nil {
c.Status(http.StatusConflict)
return
}
c.Status(http.StatusNoContent)
}

218
webdav/webdav_test.go Normal file
View file

@ -0,0 +1,218 @@
// 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 (
"bytes"
"encoding/xml"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/infinite-iroha/touka"
)
func setupTestServer(handler *Handler) *touka.Engine {
r := touka.New()
webdavMethods := []string{
"OPTIONS", "GET", "HEAD", "DELETE", "PUT", "MKCOL", "COPY", "MOVE", "PROPFIND", "PROPPATCH",
}
r.HandleFunc(webdavMethods, "/*path", handler.ServeTouka)
return r
}
func TestHandleOptions(t *testing.T) {
fs := NewMemFS()
handler := NewHandler("/", fs, NewMemLock(), nil)
r := setupTestServer(handler)
req, _ := http.NewRequest("OPTIONS", "/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d; got %d", http.StatusOK, w.Code)
}
if w.Header().Get("DAV") != "1, 2" {
t.Errorf("Expected DAV header '1, 2'; got '%s'", w.Header().Get("DAV"))
}
expectedAllow := "OPTIONS, GET, HEAD, DELETE, PUT, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH, LOCK, UNLOCK"
if w.Header().Get("Allow") != expectedAllow {
t.Errorf("Expected Allow header '%s'; got '%s'", expectedAllow, w.Header().Get("Allow"))
}
}
func TestHandleMkcol(t *testing.T) {
fs := NewMemFS()
handler := NewHandler("/", fs, NewMemLock(), nil)
r := setupTestServer(handler)
req, _ := http.NewRequest("MKCOL", "/testdir", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("Expected status %d; got %d", http.StatusCreated, w.Code)
}
// Verify the directory was created
info, err := fs.Stat(nil, "/testdir")
if err != nil {
t.Fatalf("fs.Stat failed: %v", err)
}
if !info.IsDir() {
t.Errorf("Expected '/testdir' to be a directory")
}
}
func TestHandlePropfind(t *testing.T) {
fs := NewMemFS()
handler := NewHandler("/", fs, NewMemLock(), nil)
r := setupTestServer(handler)
// Create a test directory and a test file
fs.Mkdir(nil, "/testdir", 0755)
file, _ := fs.OpenFile(nil, "/testdir/testfile", os.O_CREATE|os.O_WRONLY, 0644)
file.Write([]byte("test content"))
file.Close()
propfindBody := `<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:allprop/>
</D:propfind>`
req, _ := http.NewRequest("PROPFIND", "/testdir", bytes.NewBufferString(propfindBody))
req.Header.Set("Depth", "1")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusMultiStatus {
t.Fatalf("Expected status %d; got %d", http.StatusMultiStatus, w.Code)
}
var ms Multistatus
if err := xml.Unmarshal(w.Body.Bytes(), &ms); err != nil {
t.Fatalf("Failed to unmarshal propfind response: %v", err)
}
if len(ms.Responses) != 2 {
t.Fatalf("Expected 2 responses; got %d", len(ms.Responses))
}
// Note: The order of responses is not guaranteed.
var dirResp, fileResp *Response
for _, resp := range ms.Responses {
if resp.Href[0] == "/testdir" {
dirResp = resp
} else if resp.Href[0] == "/testdir/testfile" {
fileResp = resp
}
}
if dirResp == nil {
t.Fatal("Response for directory not found")
}
if fileResp == nil {
t.Fatal("Response for file not found")
}
// Check directory properties
if dirResp.Propstats[0].Prop.ResourceType.Collection == nil {
t.Error("Directory should have a collection resourcetype")
}
// Check file properties
if fileResp.Propstats[0].Prop.ResourceType.Collection != nil {
t.Error("File should not have a collection resourcetype")
}
if *fileResp.Propstats[0].Prop.GetContentLength != "12" {
t.Errorf("Expected content length 12; got %s", *fileResp.Propstats[0].Prop.GetContentLength)
}
}
func TestHandlePutGetDelete(t *testing.T) {
fs := NewMemFS()
handler := NewHandler("/", fs, NewMemLock(), nil)
r := setupTestServer(handler)
// PUT
putReq, _ := http.NewRequest("PUT", "/test.txt", bytes.NewBufferString("hello"))
putRec := httptest.NewRecorder()
r.ServeHTTP(putRec, putReq)
if putRec.Code != http.StatusCreated {
t.Errorf("PUT: expected status %d, got %d", http.StatusCreated, putRec.Code)
}
// GET
getReq, _ := http.NewRequest("GET", "/test.txt", nil)
getRec := httptest.NewRecorder()
r.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Errorf("GET: expected status %d, got %d", http.StatusOK, getRec.Code)
}
if getRec.Body.String() != "hello" {
t.Errorf("GET: expected body 'hello', got '%s'", getRec.Body.String())
}
// DELETE
delReq, _ := http.NewRequest("DELETE", "/test.txt", nil)
delRec := httptest.NewRecorder()
r.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusNoContent {
t.Errorf("DELETE: expected status %d, got %d", http.StatusNoContent, delRec.Code)
}
// Verify deletion
_, err := fs.Stat(nil, "/test.txt")
if !os.IsNotExist(err) {
t.Errorf("File should have been deleted, but stat returned: %v", err)
}
}
func TestHandleCopyMove(t *testing.T) {
fs := NewMemFS()
handler := NewHandler("/", fs, NewMemLock(), nil)
r := setupTestServer(handler)
// Create source file
putReq, _ := http.NewRequest("PUT", "/src.txt", bytes.NewBufferString("copy me"))
putRec := httptest.NewRecorder()
r.ServeHTTP(putRec, putReq)
// COPY
copyReq, _ := http.NewRequest("COPY", "/src.txt", nil)
copyReq.Header.Set("Destination", "/dest.txt")
copyRec := httptest.NewRecorder()
r.ServeHTTP(copyRec, copyReq)
if copyRec.Code != http.StatusCreated {
t.Errorf("COPY: expected status %d, got %d", http.StatusCreated, copyRec.Code)
}
// Verify copy
info, err := fs.Stat(nil, "/dest.txt")
if err != nil {
t.Fatalf("Stat on copied file failed: %v", err)
}
if info.Size() != int64(len("copy me")) {
t.Errorf("Copied file has wrong size")
}
// MOVE
moveReq, _ := http.NewRequest("MOVE", "/dest.txt", nil)
moveReq.Header.Set("Destination", "/moved.txt")
moveRec := httptest.NewRecorder()
r.ServeHTTP(moveRec, moveReq)
if moveRec.Code != http.StatusCreated {
t.Errorf("MOVE: expected status %d, got %d", http.StatusCreated, moveRec.Code)
}
// Verify move
if _, err := fs.Stat(nil, "/dest.txt"); !os.IsNotExist(err) {
t.Error("Original file should have been removed after move")
}
if _, err := fs.Stat(nil, "/moved.txt"); err != nil {
t.Error("Moved file not found")
}
}