This commit is contained in:
google-labs-jules[bot] 2025-12-15 00:06:38 +08:00 committed by GitHub
commit e7dfe3838c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1715 additions and 9 deletions

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

@ -0,0 +1,31 @@
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)
}
// Serve the "public" directory on the "/webdav/" route.
closer, err := webdav.Serve(r, "/webdav", "public")
if err != nil {
log.Fatal(err)
}
defer closer.Close()
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,32 @@ var (
MethodTrace = "TRACE"
)
var (
// WebDAV methods
MethodPropfind = "PROPFIND"
MethodProppatch = "PROPPATCH"
MethodMkcol = "MKCOL"
MethodCopy = "COPY"
MethodMove = "MOVE"
MethodLock = "LOCK"
MethodUnlock = "UNLOCK"
)
var MethodsSet = map[string]struct{}{
MethodGet: {},
MethodHead: {},
MethodPost: {},
MethodPut: {},
MethodPatch: {},
MethodDelete: {},
MethodConnect: {},
MethodOptions: {},
MethodTrace: {},
MethodGet: {},
MethodHead: {},
MethodPost: {},
MethodPut: {},
MethodPatch: {},
MethodDelete: {},
MethodConnect: {},
MethodOptions: {},
MethodTrace: {},
MethodPropfind: {},
MethodProppatch: {},
MethodMkcol: {},
MethodCopy: {},
MethodMove: {},
MethodLock: {},
MethodUnlock: {},
}

47
webdav/easy.go Normal file
View file

@ -0,0 +1,47 @@
// 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 (
"io"
"log"
"os"
"github.com/infinite-iroha/touka"
)
// Config is a configuration for the WebDAV handler.
type Config struct {
FileSystem FileSystem
LockSystem LockSystem
Logger Logger
}
// Register registers a WebDAV handler on the given router.
func Register(engine *touka.Engine, prefix string, cfg *Config) {
handler := NewHandler(prefix, cfg.FileSystem, cfg.LockSystem, cfg.Logger)
webdavMethods := []string{
"OPTIONS", "GET", "HEAD", "DELETE", "PUT", "MKCOL", "COPY", "MOVE", "PROPFIND", "PROPPATCH", "LOCK", "UNLOCK",
}
engine.HandleFunc(webdavMethods, prefix+"/*path", handler.ServeTouka)
}
// Serve serves a local directory via WebDAV.
func Serve(engine *touka.Engine, prefix string, rootDir string) (io.Closer, error) {
fs, err := NewOSFS(rootDir)
if err != nil {
return nil, err
}
ls := NewMemLock()
cfg := &Config{
FileSystem: fs,
LockSystem: ls,
Logger: log.New(os.Stdout, "", 0),
}
Register(engine, prefix, cfg)
return ls, nil
}

53
webdav/easy_test.go Normal file
View file

@ -0,0 +1,53 @@
// 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 (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/infinite-iroha/touka"
)
func TestRegister(t *testing.T) {
r := touka.New()
cfg := &Config{
FileSystem: NewMemFS(),
LockSystem: NewMemLock(),
}
Register(r, "/dav", cfg)
// Check if a WebDAV method is registered
req, _ := http.NewRequest("PROPFIND", "/dav/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Errorf("Expected PROPFIND to be registered, but got 404")
}
}
func TestServe(t *testing.T) {
r := touka.New()
dir, _ := os.MkdirTemp("", "webdav")
defer os.RemoveAll(dir)
closer, err := Serve(r, "/serve", dir)
if err != nil {
t.Fatalf("Serve failed: %v", err)
}
defer closer.Close()
// Check if a WebDAV method is registered
req, _ := http.NewRequest("OPTIONS", "/serve/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected OPTIONS to return 200, but got %d", w.Code)
}
}

329
webdav/memfs.go Normal file
View file

@ -0,0 +1,329 @@
// 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"
"sync/atomic"
"time"
"github.com/infinite-iroha/touka"
)
// 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 == "" || part == "." {
continue
}
if part == ".." {
if current.parent != nil {
current = current.parent
}
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(c *touka.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
atomic.StoreInt64(&node.size, 0)
}
mf := memFilePool.Get().(*memFile)
mf.node = node
mf.fs = fs
mf.offset = 0
mf.fullPath = name
mf.contentLength = c.Request.ContentLength
return mf, 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()
cleanPath := path.Clean(name)
if cleanPath == "/" {
return os.ErrInvalid
}
dir, base := path.Split(cleanPath)
parent, err := fs.findNode(dir)
if err != nil {
return err
}
node, exists := parent.children[base]
if !exists {
return os.ErrNotExist
}
var recursiveDelete func(*memNode)
recursiveDelete = func(n *memNode) {
if n.isDir {
for _, child := range n.children {
recursiveDelete(child)
}
}
n.parent = nil
n.children = nil
n.data = nil
}
recursiveDelete(node)
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 atomic.LoadInt64(&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
contentLength int64
}
var memFilePool = sync.Pool{
New: func() interface{} {
return &memFile{}
},
}
func (f *memFile) Close() error {
f.node = nil
f.fs = nil
memFilePool.Put(f)
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()
writeEnd := f.offset + int64(len(p))
// Grow slice if necessary
if writeEnd > int64(cap(f.node.data)) {
newCap := int64(cap(f.node.data)) * 2
if newCap < writeEnd {
newCap = writeEnd
}
newData := make([]byte, len(f.node.data), newCap)
copy(newData, f.node.data)
f.node.data = newData
}
// Extend slice length if write goes past the end
if writeEnd > int64(len(f.node.data)) {
f.node.data = f.node.data[:writeEnd]
}
n = copy(f.node.data[f.offset:], p)
f.offset += int64(n)
// Update size only if the file has grown
if f.offset > atomic.LoadInt64(&f.node.size) {
atomic.StoreInt64(&f.node.size, f.offset)
}
f.node.modTime = time.Now()
return n, nil
}
func (f *memFile) Seek(offset int64, whence int) (int64, error) {
f.fs.mu.Lock()
defer f.fs.mu.Unlock()
var newOffset int64
switch whence {
case io.SeekStart:
newOffset = offset
case io.SeekCurrent:
newOffset = f.offset + offset
case io.SeekEnd:
newOffset = f.node.size + offset
default:
return 0, os.ErrInvalid
}
if newOffset < 0 {
return 0, os.ErrInvalid
}
f.offset = newOffset
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
}

111
webdav/memlock.go Normal file
View file

@ -0,0 +1,111 @@
// 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
stop chan struct{}
}
type lock struct {
token string
path string
expires time.Time
info LockInfo
}
// NewMemLock creates a new in-memory lock system.
func NewMemLock() *MemLock {
l := &MemLock{
locks: make(map[string]*lock),
stop: make(chan struct{}),
}
go l.cleanup()
return l
}
// Close stops the cleanup goroutine.
func (l *MemLock) Close() error {
close(l.stop)
return nil
}
func (l *MemLock) cleanup() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
l.mu.Lock()
for token, lock := range l.locks {
if time.Now().After(lock.expires) {
delete(l.locks, token)
}
}
l.mu.Unlock()
case <-l.stop:
return
}
}
}
// 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()
// Check for conflicting locks
for _, v := range l.locks {
if v.path == path {
return "", os.ErrExist
}
}
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
return "", err
}
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
}

148
webdav/osfs.go Normal file
View file

@ -0,0 +1,148 @@
// 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"
"github.com/infinite-iroha/touka"
)
// 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) {
if strings.Contains(name, "..") {
return "", os.ErrPermission
}
path := filepath.Join(fs.RootDir, name)
// Evaluate symlinks, but only if the path exists.
if _, err := os.Lstat(path); err == nil {
path, err = filepath.EvalSymlinks(path)
if err != nil {
return "", err
}
} else if !os.IsNotExist(err) {
return "", err
// For non-existent paths (like for PUT or MKCOL), we can't EvalSymlinks the full path.
// Instead, we resolve the parent and ensure it's within the root.
} else {
parentDir := filepath.Dir(path)
if _, err := os.Stat(parentDir); err == nil {
parentDir, err = filepath.EvalSymlinks(parentDir)
if err != nil {
return "", err
}
path = filepath.Join(parentDir, filepath.Base(path))
}
}
rel, err := filepath.Rel(fs.RootDir, path)
if err != nil {
return "", err
}
if strings.HasPrefix(rel, "..") {
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(c *touka.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)
}

751
webdav/webdav.go Normal file
View file

@ -0,0 +1,751 @@
// 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)
}

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(&touka.Context{Request: &http.Request{}}, "/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")
}
}