752 lines
18 KiB
Go
752 lines
18 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"mime"
|
||
"net/http"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// ---- Minimal WebDAV 实现 ----
|
||
|
||
// 内存锁系统
|
||
type webdavLock struct {
|
||
token string
|
||
path string
|
||
expires time.Time
|
||
owner string
|
||
}
|
||
|
||
type memLS struct {
|
||
mu sync.Mutex
|
||
locks map[string]*webdavLock
|
||
}
|
||
|
||
func newMemLS() *memLS {
|
||
return &memLS{locks: make(map[string]*webdavLock)}
|
||
}
|
||
|
||
func (ls *memLS) create(path, owner string, timeout time.Duration) string {
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
token := fmt.Sprintf("opaquelocktoken:%x", time.Now().UnixNano())
|
||
ls.locks[token] = &webdavLock{
|
||
token: token,
|
||
path: path,
|
||
owner: owner,
|
||
}
|
||
if timeout > 0 {
|
||
ls.locks[token].expires = time.Now().Add(timeout)
|
||
}
|
||
return token
|
||
}
|
||
|
||
func (ls *memLS) refresh(token string) {
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
if l, ok := ls.locks[token]; ok {
|
||
l.expires = time.Now().Add(5 * time.Minute)
|
||
}
|
||
}
|
||
|
||
func (ls *memLS) unlock(token string) bool {
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
_, ok := ls.locks[token]
|
||
delete(ls.locks, token)
|
||
return ok
|
||
}
|
||
|
||
func (ls *memLS) isLocked(path string) bool {
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
// 清理过期锁
|
||
now := time.Now()
|
||
for _, l := range ls.locks {
|
||
if !l.expires.IsZero() && now.After(l.expires) {
|
||
delete(ls.locks, l.token)
|
||
}
|
||
}
|
||
// 检查路径本身及其所有父路径
|
||
for _, l := range ls.locks {
|
||
if l.path == path || strings.HasPrefix(path, l.path+"/") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
var lockSystem = newMemLS()
|
||
|
||
// checkLocked 如果路径被锁定则返回 423 Locked 响应,true 表示已锁定
|
||
func checkLocked(w http.ResponseWriter, p string) bool {
|
||
if lockSystem.isLocked(p) {
|
||
http.Error(w, "Resource is locked", http.StatusLocked)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ---- XML 结构 ----
|
||
|
||
type propfindXML struct {
|
||
XMLName xml.Name `xml:"DAV: propfind"`
|
||
Prop propXML `xml:"prop"`
|
||
}
|
||
|
||
type propXML struct {
|
||
XMLName xml.Name `xml:"DAV: prop"`
|
||
CreationDate string `xml:"DAV: creationdate,omitempty"`
|
||
DisplayName string `xml:"DAV: displayname,omitempty"`
|
||
GetContentLanguage string `xml:"DAV: getcontentlanguage,omitempty"`
|
||
GetContentLength string `xml:"DAV: getcontentlength,omitempty"`
|
||
GetContentType string `xml:"DAV: getcontenttype,omitempty"`
|
||
GetETag string `xml:"DAV: getetag,omitempty"`
|
||
GetLastModified string `xml:"DAV: getlastmodified,omitempty"`
|
||
ResourceType *resourceTypeXML `xml:"DAV: resourcetype,omitempty"`
|
||
SupportedLock string `xml:"DAV: supportedlock,omitempty"`
|
||
IsCollection string `xml:"DAV: iscollection,omitempty"`
|
||
AFIComment string `xml:"AFI: comment,omitempty"`
|
||
}
|
||
|
||
type resourceTypeXML struct {
|
||
Collection *struct{} `xml:"DAV: collection"`
|
||
}
|
||
|
||
type multistatusXML struct {
|
||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||
Xmlns string `xml:"xmlns,attr"`
|
||
XmlnsAFI string `xml:"xmlns:AFI,attr"`
|
||
Responses []responseXML `xml:"response"`
|
||
}
|
||
|
||
type responseXML struct {
|
||
XMLName xml.Name `xml:"DAV: response"`
|
||
Href string `xml:"href"`
|
||
Propstats []propstatXML `xml:"propstat"`
|
||
}
|
||
|
||
type propstatXML struct {
|
||
XMLName xml.Name `xml:"DAV: propstat"`
|
||
Status string `xml:"status"`
|
||
Prop propXML `xml:"prop"`
|
||
}
|
||
|
||
type lockDiscoveryXML struct {
|
||
XMLName xml.Name `xml:"DAV: lockdiscovery"`
|
||
ActiveLock *activeLockXML `xml:"activelock,omitempty"`
|
||
}
|
||
|
||
type activeLockXML struct {
|
||
LockType lockTypeXML `xml:"locktype"`
|
||
LockScope lockScopeXML `xml:"lockscope"`
|
||
Depth string `xml:"depth"`
|
||
Owner *ownerXML `xml:"owner,omitempty"`
|
||
Timeout string `xml:"timeout"`
|
||
LockToken *lockTokenXML `xml:"locktoken,omitempty"`
|
||
LockRoot string `xml:"lockroot"`
|
||
}
|
||
|
||
type lockTypeXML struct {
|
||
Write string `xml:"write"`
|
||
}
|
||
|
||
type lockScopeXML struct {
|
||
Exclusive string `xml:"exclusive"`
|
||
}
|
||
|
||
type ownerXML struct {
|
||
Href string `xml:"href"`
|
||
}
|
||
|
||
type lockTokenXML struct {
|
||
Href string `xml:"href"`
|
||
}
|
||
|
||
// ---- WebDAV Handler ----
|
||
|
||
func webdavHandler(w http.ResponseWriter, r *http.Request) {
|
||
// OPTIONS 提前放行(免认证)
|
||
if r.Method == "OPTIONS" {
|
||
w.Header().Set("DAV", "1")
|
||
w.Header().Set("Allow", "GET, HEAD, PUT, DELETE, PROPFIND, MKCOL, MOVE, COPY, LOCK, UNLOCK, OPTIONS")
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
// PROPFIND / GET / HEAD 免认证,其余方法需要认证
|
||
readMethod := r.Method == "PROPFIND" || r.Method == "GET" || r.Method == "HEAD"
|
||
if !readMethod && !checkAuthRequest(w, r) {
|
||
w.Header().Set("WWW-Authenticate", `Basic realm="AFI WebDAV"`)
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// 去掉前缀得到内部路径
|
||
davPrefix := *extraPath + "dav"
|
||
p := strings.TrimPrefix(r.URL.Path, davPrefix)
|
||
if !strings.HasPrefix(p, "/") {
|
||
p = "/" + p
|
||
}
|
||
fullPath := enforcePath(p)
|
||
|
||
switch r.Method {
|
||
case "PROPFIND":
|
||
handlePropfind(w, r, p, fullPath)
|
||
case "PROPPATCH":
|
||
handleProppatch(w, r, p, fullPath)
|
||
case "MKCOL":
|
||
handleMkcol(w, r, p, fullPath)
|
||
case "GET", "HEAD":
|
||
handleGet(w, r, fullPath)
|
||
case "PUT":
|
||
handlePut(w, r, p, fullPath)
|
||
case "DELETE":
|
||
handleDelete(w, r, p, fullPath)
|
||
case "MOVE":
|
||
handleMove(w, r, p, fullPath, davPrefix)
|
||
case "COPY":
|
||
handleCopy(w, r, p, fullPath, davPrefix)
|
||
case "LOCK":
|
||
handleLock(w, r, p)
|
||
case "UNLOCK":
|
||
handleUnlock(w, r, p)
|
||
default:
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
func handlePropfind(w http.ResponseWriter, r *http.Request, p, fullPath string) {
|
||
stat, err := os.Stat(fullPath)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
depth := r.Header.Get("Depth")
|
||
if depth == "" {
|
||
depth = "1"
|
||
}
|
||
|
||
var entries []responseXML
|
||
|
||
if stat.IsDir() && depth != "0" {
|
||
if depth == "infinity" {
|
||
// 递归遍历所有子目录(Depth: infinity)
|
||
filepath.Walk(fullPath, func(walkPath string, walkInfo os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if walkPath == fullPath {
|
||
return nil
|
||
}
|
||
if *skipHidden && strings.HasPrefix(filepath.Base(walkPath), ".") {
|
||
if walkInfo.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if !*symlinks && walkInfo.Mode()&os.ModeSymlink != 0 {
|
||
return nil
|
||
}
|
||
relPath, _ := filepath.Rel(fullPath, walkPath)
|
||
href := path.Clean(p + "/" + filepath.ToSlash(relPath))
|
||
if walkInfo.IsDir() && !strings.HasSuffix(href, "/") {
|
||
href += "/"
|
||
}
|
||
// 从所在目录的 DESCRIPT.ION 读取注释
|
||
dirDesc := parseDescriptIon(filepath.Dir(walkPath))
|
||
comment := dirDesc[walkInfo.Name()]
|
||
entries = append(entries, makePropResponse(href, walkInfo, walkInfo.IsDir(), comment))
|
||
return nil
|
||
})
|
||
} else {
|
||
// depth "1" - 只列出直接子项
|
||
files, err := os.ReadDir(fullPath)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
dirDesc := parseDescriptIon(fullPath)
|
||
for _, entry := range files {
|
||
name := entry.Name()
|
||
if *skipHidden && strings.HasPrefix(name, ".") {
|
||
continue
|
||
}
|
||
childPath := path.Clean(p + "/" + name)
|
||
childFull := filepath.Join(fullPath, name)
|
||
childStat, err := os.Stat(childFull)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
isDir := entry.IsDir()
|
||
entries = append(entries, makePropResponse(childPath, childStat, isDir, dirDesc[name]))
|
||
}
|
||
}
|
||
} else {
|
||
isDir := stat.IsDir()
|
||
href := path.Clean(p)
|
||
if isDir && !strings.HasSuffix(href, "/") {
|
||
href += "/"
|
||
}
|
||
parentDesc := parseDescriptIon(filepath.Dir(fullPath))
|
||
entries = append(entries, makePropResponse(href, stat, isDir, parentDesc[stat.Name()]))
|
||
}
|
||
|
||
writeMultistatus(w, entries)
|
||
}
|
||
|
||
func makePropResponse(href string, fi os.FileInfo, isDir bool, comment string) responseXML {
|
||
var rt *resourceTypeXML
|
||
if isDir {
|
||
rt = &resourceTypeXML{Collection: &struct{}{}}
|
||
}
|
||
|
||
size := ""
|
||
if !isDir {
|
||
size = fmt.Sprintf("%d", fi.Size())
|
||
}
|
||
|
||
mimeType := ""
|
||
if !isDir {
|
||
mimeType = "application/octet-stream"
|
||
if ext := filepath.Ext(href); ext != "" {
|
||
if t := mime.TypeByExtension(ext); t != "" {
|
||
mimeType = t
|
||
}
|
||
}
|
||
}
|
||
|
||
etag := fmt.Sprintf(`"%x-%x"`, fi.Size(), fi.ModTime().UnixNano())
|
||
lastMod := fi.ModTime().UTC().Format(http.TimeFormat)
|
||
created := fi.ModTime().UTC().Format(time.RFC3339)
|
||
|
||
return responseXML{
|
||
Href: href,
|
||
Propstats: []propstatXML{{
|
||
Status: "HTTP/1.1 200 OK",
|
||
Prop: propXML{
|
||
CreationDate: created,
|
||
DisplayName: path.Base(href),
|
||
GetContentLength: size,
|
||
GetContentType: mimeType,
|
||
GetETag: etag,
|
||
GetLastModified: lastMod,
|
||
ResourceType: rt,
|
||
IsCollection: fmt.Sprintf("%t", isDir),
|
||
AFIComment: comment,
|
||
},
|
||
}},
|
||
}
|
||
}
|
||
|
||
func writeMultistatus(w http.ResponseWriter, entries []responseXML) {
|
||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||
w.WriteHeader(http.StatusMultiStatus)
|
||
resp := multistatusXML{
|
||
Xmlns: "DAV:",
|
||
XmlnsAFI: "AFI:",
|
||
Responses: entries,
|
||
}
|
||
enc := xml.NewEncoder(w)
|
||
enc.Indent("", " ")
|
||
w.Write([]byte(xml.Header))
|
||
enc.Encode(resp)
|
||
}
|
||
|
||
func handleMkcol(w http.ResponseWriter, _ *http.Request, p, fullPath string) {
|
||
if checkLocked(w, p) {
|
||
return
|
||
}
|
||
err := os.MkdirAll(fullPath, os.ModePerm)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusConflict)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusCreated)
|
||
}
|
||
|
||
func handlePut(w http.ResponseWriter, r *http.Request, p, fullPath string) {
|
||
if checkLocked(w, p) {
|
||
return
|
||
}
|
||
// Create parent dirs if needed
|
||
os.MkdirAll(filepath.Dir(fullPath), os.ModePerm)
|
||
|
||
f, err := os.Create(fullPath)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
|
||
_, err = io.Copy(f, r.Body)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusCreated)
|
||
}
|
||
|
||
func handleDelete(w http.ResponseWriter, _ *http.Request, p, fullPath string) {
|
||
if checkLocked(w, p) {
|
||
return
|
||
}
|
||
err := os.RemoveAll(fullPath)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func handleMove(w http.ResponseWriter, r *http.Request, p, fullPath, davPrefix string) {
|
||
if checkLocked(w, p) {
|
||
return
|
||
}
|
||
dest := r.Header.Get("Destination")
|
||
if dest == "" {
|
||
http.Error(w, "Destination header required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// 从 Destination URL 提取路径
|
||
u, err := r.URL.Parse(dest)
|
||
if err != nil {
|
||
http.Error(w, "Invalid Destination header: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
destPath := strings.TrimPrefix(u.Path, davPrefix)
|
||
destFull := enforcePath(destPath)
|
||
|
||
if checkLocked(w, destPath) {
|
||
return
|
||
}
|
||
|
||
err = os.Rename(fullPath, destFull)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func handleCopy(w http.ResponseWriter, r *http.Request, p, fullPath, davPrefix string) {
|
||
if checkLocked(w, p) {
|
||
return
|
||
}
|
||
dest := r.Header.Get("Destination")
|
||
if dest == "" {
|
||
http.Error(w, "Destination header required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
u, err := r.URL.Parse(dest)
|
||
if err != nil {
|
||
http.Error(w, "Invalid Destination header: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
destPath := strings.TrimPrefix(u.Path, davPrefix)
|
||
destFull := enforcePath(destPath)
|
||
|
||
if checkLocked(w, destPath) {
|
||
return
|
||
}
|
||
|
||
err = copyFileOrDir(fullPath, destFull)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func handleGet(w http.ResponseWriter, r *http.Request, fullPath string) {
|
||
stat, err := os.Stat(fullPath)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
if stat.IsDir() {
|
||
http.Error(w, "Not a file", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
file, err := os.Open(fullPath)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// ETag 基于文件大小和修改时间
|
||
etag := fmt.Sprintf(`"%x-%x"`, stat.Size(), stat.ModTime().UnixNano())
|
||
w.Header().Set("ETag", etag)
|
||
|
||
// Content-Type 优先用 mime.TypeByExtension
|
||
ext := filepath.Ext(stat.Name())
|
||
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
|
||
w.Header().Set("Content-Type", mimeType)
|
||
}
|
||
|
||
// ServeContent 自动处理 Range/Partial Content (206), Content-Length, Last-Modified
|
||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), file)
|
||
}
|
||
|
||
// ---- DESCRIPT.ION 读写 ----
|
||
|
||
func writeDescriptIon(dirPath, name, description string) error {
|
||
descPath := filepath.Join(dirPath, "DESCRIPT.ION")
|
||
|
||
entries := make(map[string]string)
|
||
if data, err := os.ReadFile(descPath); err == nil {
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
var targetName, desc string
|
||
if strings.HasPrefix(line, `"`) {
|
||
end := strings.Index(line[1:], `"`)
|
||
if end != -1 {
|
||
targetName = line[1 : end+1]
|
||
desc = strings.TrimSpace(line[end+2:])
|
||
}
|
||
} else {
|
||
parts := strings.SplitN(line, " ", 2)
|
||
targetName = parts[0]
|
||
if len(parts) > 1 {
|
||
desc = strings.TrimSpace(parts[1])
|
||
}
|
||
}
|
||
if targetName != "" {
|
||
cleanName := strings.Trim(targetName, `"`)
|
||
entries[cleanName] = strings.TrimSpace(desc)
|
||
}
|
||
}
|
||
}
|
||
|
||
if description == "" {
|
||
delete(entries, name)
|
||
} else {
|
||
entries[name] = description
|
||
}
|
||
|
||
// 排序后写入,保持确定性输出
|
||
var names []string
|
||
for n := range entries {
|
||
names = append(names, n)
|
||
}
|
||
sort.Strings(names)
|
||
|
||
var buf strings.Builder
|
||
for _, target := range names {
|
||
desc := entries[target]
|
||
if strings.Contains(target, " ") {
|
||
buf.WriteString(`"` + target + `" `)
|
||
} else {
|
||
buf.WriteString(target + " ")
|
||
}
|
||
buf.WriteString(desc + "\n")
|
||
}
|
||
|
||
return os.WriteFile(descPath, []byte(buf.String()), 0644)
|
||
}
|
||
|
||
// ---- PROPPATCH ----
|
||
|
||
func handleProppatch(w http.ResponseWriter, r *http.Request, p, fullPath string) {
|
||
body, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
type afiProp struct {
|
||
Comment string `xml:"AFI: comment"`
|
||
}
|
||
type setOrRemove struct {
|
||
Prop afiProp `xml:"prop"`
|
||
}
|
||
type propertyupdateXML struct {
|
||
XMLName xml.Name `xml:"DAV: propertyupdate"`
|
||
Set *setOrRemove `xml:"set"`
|
||
Remove *setOrRemove `xml:"remove"`
|
||
}
|
||
|
||
var req propertyupdateXML
|
||
if err := xml.Unmarshal(body, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
dirPath := filepath.Dir(fullPath)
|
||
name := filepath.Base(fullPath)
|
||
|
||
if req.Remove != nil {
|
||
if err := writeDescriptIon(dirPath, name, ""); err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
} else if req.Set != nil {
|
||
if err := writeDescriptIon(dirPath, name, req.Set.Prop.Comment); err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 构建响应 prop
|
||
respProp := propXML{}
|
||
if req.Set != nil {
|
||
respProp.AFIComment = req.Set.Prop.Comment
|
||
}
|
||
|
||
// 返回 207 Multi-Status
|
||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||
w.WriteHeader(http.StatusMultiStatus)
|
||
resp := multistatusXML{
|
||
Xmlns: "DAV:",
|
||
XmlnsAFI: "AFI:",
|
||
Responses: []responseXML{{
|
||
Href: p,
|
||
Propstats: []propstatXML{{
|
||
Status: "HTTP/1.1 200 OK",
|
||
Prop: respProp,
|
||
}},
|
||
}},
|
||
}
|
||
enc := xml.NewEncoder(w)
|
||
enc.Indent("", " ")
|
||
w.Write([]byte(xml.Header))
|
||
enc.Encode(resp)
|
||
}
|
||
|
||
func copyFileOrDir(src, dst string) error {
|
||
si, err := os.Stat(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if si.IsDir() {
|
||
return copyDir(src, dst)
|
||
}
|
||
|
||
return copyFile(src, dst)
|
||
}
|
||
|
||
func copyFile(src, dst string) error {
|
||
os.MkdirAll(filepath.Dir(dst), os.ModePerm)
|
||
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer out.Close()
|
||
|
||
_, err = io.Copy(out, in)
|
||
return err
|
||
}
|
||
|
||
func copyDir(src, dst string) error {
|
||
os.MkdirAll(dst, os.ModePerm)
|
||
entries, err := os.ReadDir(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, e := range entries {
|
||
if err := copyFileOrDir(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseTimeout(s string) time.Duration {
|
||
// RFC 4918: "Second-1234" or "Infinite"
|
||
if strings.HasPrefix(s, "Second-") {
|
||
n, err := strconv.Atoi(strings.TrimPrefix(s, "Second-"))
|
||
if err == nil && n > 0 {
|
||
return time.Duration(n) * time.Second
|
||
}
|
||
}
|
||
return 5 * time.Minute // 默认 5 分钟
|
||
}
|
||
|
||
func handleLock(w http.ResponseWriter, r *http.Request, p string) {
|
||
// 解析 body 获取 owner
|
||
owner := ""
|
||
if r.Body != nil {
|
||
body, _ := io.ReadAll(r.Body)
|
||
var lockReq struct {
|
||
Owner struct {
|
||
Href string `xml:"href"`
|
||
} `xml:"owner"`
|
||
}
|
||
xml.Unmarshal(body, &lockReq)
|
||
owner = lockReq.Owner.Href
|
||
}
|
||
|
||
timeout := parseTimeout(r.Header.Get("Timeout"))
|
||
token := lockSystem.create(p, owner, timeout)
|
||
|
||
timeoutStr := "Infinite"
|
||
if timeout > 0 {
|
||
timeoutStr = fmt.Sprintf("Second-%d", int(timeout.Seconds()))
|
||
}
|
||
|
||
w.Header().Set("Lock-Token", "<"+token+">")
|
||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||
w.WriteHeader(http.StatusOK)
|
||
|
||
resp := struct {
|
||
XMLName xml.Name `xml:"DAV: prop"`
|
||
LockDiscovery lockDiscoveryXML `xml:"lockdiscovery"`
|
||
}{
|
||
LockDiscovery: lockDiscoveryXML{
|
||
ActiveLock: &activeLockXML{
|
||
LockType: lockTypeXML{Write: ""},
|
||
LockScope: lockScopeXML{Exclusive: ""},
|
||
Depth: "Infinity",
|
||
Owner: &ownerXML{Href: owner},
|
||
Timeout: timeoutStr,
|
||
LockToken: &lockTokenXML{Href: token},
|
||
LockRoot: p,
|
||
},
|
||
},
|
||
}
|
||
w.Write([]byte(xml.Header))
|
||
enc := xml.NewEncoder(w)
|
||
enc.Indent("", " ")
|
||
enc.Encode(resp)
|
||
}
|
||
|
||
func handleUnlock(w http.ResponseWriter, r *http.Request, p string) {
|
||
token := r.Header.Get("Lock-Token")
|
||
token = strings.Trim(token, "<>")
|
||
if token == "" {
|
||
http.Error(w, "Lock-Token header required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !lockSystem.unlock(token) {
|
||
http.Error(w, "No matching lock found", http.StatusConflict)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|