mirror of
https://github.com/infinite-iroha/touka.git
synced 2026-02-03 00:41:10 +08:00
Merge 0ed9fa3290 into 7b536ac137
This commit is contained in:
commit
e7dfe3838c
9 changed files with 1715 additions and 9 deletions
31
examples/webdav/main.go
Normal file
31
examples/webdav/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
touka.go
36
touka.go
|
|
@ -60,14 +60,32 @@ var (
|
||||||
MethodTrace = "TRACE"
|
MethodTrace = "TRACE"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// WebDAV methods
|
||||||
|
MethodPropfind = "PROPFIND"
|
||||||
|
MethodProppatch = "PROPPATCH"
|
||||||
|
MethodMkcol = "MKCOL"
|
||||||
|
MethodCopy = "COPY"
|
||||||
|
MethodMove = "MOVE"
|
||||||
|
MethodLock = "LOCK"
|
||||||
|
MethodUnlock = "UNLOCK"
|
||||||
|
)
|
||||||
|
|
||||||
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: {},
|
||||||
|
MethodLock: {},
|
||||||
|
MethodUnlock: {},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
webdav/easy.go
Normal file
47
webdav/easy.go
Normal 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
53
webdav/easy_test.go
Normal 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
329
webdav/memfs.go
Normal 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
111
webdav/memlock.go
Normal 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
148
webdav/osfs.go
Normal 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
751
webdav/webdav.go
Normal 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
218
webdav/webdav_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue