feat: 实现 WebDAV
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,4 +16,6 @@ afi-dev-windows.exe
|
||||
afi-dev.test
|
||||
|
||||
*.out
|
||||
.vscode
|
||||
.vscode
|
||||
|
||||
AGENTS.md
|
||||
|
||||
@@ -4,6 +4,7 @@ afi-dev 开发版本释出二进制
|
||||
afi.go 主模块文件
|
||||
afi_embed.go 嵌入资源与模板配置
|
||||
afi_embed_dev.go 热重载资源配置
|
||||
go.mod Golang 模块定义
|
||||
go.mod Go/模块定义
|
||||
benchdir 性能测试示例目录
|
||||
LICENSE MIT 协议文件
|
||||
README.md 自述文件
|
||||
|
||||
23
README.md
23
README.md
@@ -18,7 +18,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
|
||||
- 基本的用户管理系统 (基于 HTTP Basic Authication), 我们移除了 Gossa 单一的只读/可写模式 (即 -ro 参数), 并保留了未登录状态下的默认只读模式
|
||||
- 支持跳过隐藏项目 (以 `.` 开头的文件/目录)
|
||||
- 开发/生产模式分离, 便于免编译重载前端资源
|
||||
- 同时提供简易的 RPC 调用接口便于编程与 webdav 接口(TODO)便于挂载, 网页用户界面的 JavaScript 使用前者
|
||||
- 同时提供简易的 RPC 调用接口, 并加入了 WebDAV 接口便于编程与挂载, 网页用户界面的 JavaScript 使用前者
|
||||
- 低占用高性能, 网页用户界面每秒可承受万级以上响应并持续保持毫秒级低延迟, 接口性能更高
|
||||
- 默认启用 gzip 压缩, 并为 iframe 嵌入优化
|
||||
- 性能高效, 提供毫秒级响应, 用户使用体验不让位于技术审美与性能
|
||||
@@ -26,10 +26,9 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
|
||||
|
||||
## 安全与不安全设计
|
||||
|
||||
考虑到此程序可能被使用的场景与便于方案选型, 笔者必须指出此程序存在的问题, 以免您产生虚假的安全感
|
||||
考虑到此程序可能被使用的场景与便于方案选型, 笔者在此指出此程序存在的问题, 以免产生虚假的安全感
|
||||
如果您在为安全性抉择是否使用此项目, 我们高度建议您不要使用它
|
||||
如您发现了其他可能的漏洞, 请在 issues 指出, 若属实, 笔者会将其修复或加入下方列表, 并且将您的名字加入致谢名单
|
||||
注意: 请不要把"文档写出来了"等同于"只有这些问题"(我们当然追求安全审计文档的完善度)
|
||||
|
||||
安全建议:
|
||||
|
||||
@@ -49,7 +48,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
|
||||
|
||||
不安全设计:
|
||||
- 不能防范且无法容忍的攻击
|
||||
- 如上文所述, 这里没写不等于没有, 欢迎通过 issues 提出
|
||||
- 没发现, 但这里没写不等于没有, 欢迎通过 issues 提出
|
||||
- 未防范但几乎不可能成功的攻击
|
||||
- Timing Attack (未防范, 密码在后端明文比较, 但可通过设置较不寻常的用户名规避) -> 我们未来大概率不会改
|
||||
- 能防范的攻击
|
||||
@@ -63,7 +62,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
|
||||
- DoS 攻击 - 我们认为这件事应该由 nginx 反向代理预防
|
||||
- 请求大小和配额无限制导致的超大文件占用和可能导致的 OOM 崩溃 - 我们认为这件事应该由 nginx 反向代理或磁盘配额限制预防, 并且就事实而言, 内部威胁不在模型内, 这很大程度上是有权限的用户行为不当引起的问题
|
||||
- 无自带的响应超时: 可能招致 Slowloris 服务瘫痪 - 但这可由 nginx 反向代理预防
|
||||
- 权限提升风险: AFI 以什么用户运行, 就有什么权限 -- 如果以 root 运行且将分享目录设置为根目录, 则任何登录用户都能删除系统, 或者通过上传 `.ssh/authorized_keys` 或 `.bashrc` 远程控制您的服务器操作系统 -> 我们改不了
|
||||
- 权限提升风险: AFI 以什么用户运行, 就有什么权限 - 如果以 root 运行且将分享目录设置为根目录, 则任何登录用户都能删除系统, 或者通过上传 `.ssh/authorized_keys` 或 `.bashrc` 远程控制您的服务器操作系统 -> 我们改不了
|
||||
- 未默认杜绝的隐私问题:
|
||||
- 外部字体 Referer 泄露 (仅会得知您访问的链接, 但如果您需要, 可以移除前端中引用的在线字体链接) 就事实而言, 我们认为外部字体提供商不太可能有收集这种数据的动机, 但如果您在高敏感内网部署, 则您可能不希望任何外部服务能够得知内网的文件结构
|
||||
- 日志可能泄漏敏感信息 (可以通过修改源代码编译解决, 但我们提供的二进制释出没有移除敏感信息输出)
|
||||
@@ -115,18 +114,18 @@ export GOBIN=$GOPATH/bin/
|
||||
```text
|
||||
Test Platform: AMD Ryzen 3600X, tmpfs, 12 item with DESCRIPT.ION in directory
|
||||
OS: glibc linux-zen 6.19.10
|
||||
Date: 2026-04-02
|
||||
Date: 2026-04-02, 04-12
|
||||
Version: latest
|
||||
Command: wrk -t8 -c50 -d15s http://addr/
|
||||
Test Address: /
|
||||
|
||||
QPS Performace from test:
|
||||
Server QPS Data QPS Bar (higher is better) Gzip Page
|
||||
Nginx 60,242 QPS ████████████████████████████████ Disabled Default Index
|
||||
Apache 34,207 QPS █████████████████░░░░░░░░░░░░░░░ Disabled Default Index
|
||||
AFI 15,262 QPS ████████░░░░░░░░░░░░░░░░░░░░░░░░ BestSpeed Full-functional web file manager (without CSS & JS embedded in bench) (net/http)
|
||||
PyPy 4,169 QPS ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled Default Index (http.server)
|
||||
CPython 2,128 QPS █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled Default Index (http.server)
|
||||
Server QPS Data QPS Bar (higher is better) Gzip Version Page
|
||||
Nginx 60,242 QPS ████████████████████████████████ Disabled 1.28.3 Default Index
|
||||
Apache 34,207 QPS █████████████████░░░░░░░░░░░░░░░ Disabled 2.4.66 Default Index
|
||||
AFI 15,262 QPS ████████░░░░░░░░░░░░░░░░░░░░░░░░ BestSpeed 26.4.3 Web Interface (without CSS & JS embedded in bench) (net/http)
|
||||
PyPy 4,169 QPS ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled 7.3.21 Default Index (http.server)
|
||||
CPython 2,128 QPS █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled 3.14.3 Default Index (http.server)
|
||||
|
||||
Latency from the same test:
|
||||
Server Average Latency Bar (lower is better) Maximum
|
||||
|
||||
194
afi.go
194
afi.go
@@ -31,7 +31,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const ver = "26.04.03"
|
||||
const ver = "26.4.26"
|
||||
|
||||
// rowTemplate 定义文件列表中的每一行数据结构
|
||||
type rowTemplate struct {
|
||||
@@ -64,6 +64,7 @@ var skipHidden = flag.Bool("k", false, "跳过隐藏文件")
|
||||
// var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式
|
||||
var title = flag.String("title", "%PATH%", "页面标题, 用 %PATH% 指代完整路径, %ITEM% 指代末端文件/目录名, 不会泄露根目录目录名")
|
||||
var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据 (也可以用环境变量 AFI_AUTH 设置, AFI_AUTH 是最优先且最安全的)") // 这个flag只能用来调试 不然服务器一个ps就泄露了
|
||||
var dav = flag.Bool("dav", true, "启用 WebDAV 接口")
|
||||
|
||||
// rpcCall 定义RPC调用的JSON数据结构
|
||||
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
|
||||
@@ -116,65 +117,57 @@ func humanize(bytes int64) string {
|
||||
}
|
||||
}
|
||||
|
||||
// lookupDesc 返回指定文件在 DESCRIPT.ION 中的注释内容
|
||||
func lookupDesc(fullPath string) string {
|
||||
// 1. 获取绝对路径并提取目录和文件名
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(absPath)
|
||||
fileName := filepath.Base(absPath)
|
||||
// parseDescriptIon 只打开一次 DESCRIPT.ION 并返回 map,大幅提升目录列表性能
|
||||
func parseDescriptIon(dirPath string) map[string]string {
|
||||
descMap := make(map[string]string)
|
||||
descFilePath := filepath.Join(dirPath, "DESCRIPT.ION")
|
||||
|
||||
if fileName == "DESCRIPT.ION" {
|
||||
return "备注元数据"
|
||||
}
|
||||
// 默认描述, 可被覆写
|
||||
descMap["DESCRIPT.ION"] = "备注元数据"
|
||||
descMap["README.md"] = "自述文件"
|
||||
descMap["LICENSE"] = "许可证"
|
||||
descMap[".gitignore"] = "Git/文件忽略模式"
|
||||
descMap[".git"] = "Git/裸仓库目录"
|
||||
|
||||
// 2. 尝试打开该目录下的 DESCRIPT.ION (不区分大小写)
|
||||
// 在 Windows 下直接打开即可,在 Linux 下可能需要遍历目录匹配文件名
|
||||
descFilePath := filepath.Join(dir, "DESCRIPT.ION")
|
||||
file, err := os.Open(descFilePath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
if file, err := os.Open(descFilePath); err == nil {
|
||||
defer file.Close()
|
||||
|
||||
// 3. 逐行扫描文件内容
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var targetName, description string
|
||||
|
||||
// DESCRIPT.ION 格式处理:
|
||||
// 如果文件名包含空格,通常会被双引号包裹,例如: "My Photo.jpg" This is a photo
|
||||
if strings.HasPrefix(line, "\"") {
|
||||
endQuoteIndex := strings.Index(line[1:], "\"")
|
||||
if endQuoteIndex != -1 {
|
||||
targetName = line[1 : endQuoteIndex+1]
|
||||
description = strings.TrimSpace(line[endQuoteIndex+2:])
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 普通格式:文件名 描述 (空格分隔)
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
targetName = parts[0]
|
||||
if len(parts) > 1 {
|
||||
description = strings.TrimSpace(parts[1])
|
||||
|
||||
var targetName, description string
|
||||
|
||||
// 处理带双引号的文件名格式,例如:"My Photo.jpg" This is a description
|
||||
if strings.HasPrefix(line, `"`) {
|
||||
end := strings.Index(line[1:], `"`)
|
||||
if end != -1 {
|
||||
targetName = line[1 : end+1]
|
||||
description = strings.TrimSpace(line[end+2:])
|
||||
}
|
||||
} else {
|
||||
// 普通格式:filename description
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
targetName = parts[0]
|
||||
if len(parts) > 1 {
|
||||
description = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if targetName != "" {
|
||||
// 清理可能的控制字符
|
||||
description = strings.Split(description, "\x04")[0]
|
||||
// 严格大小写:去掉可能的引号后,直接使用原始名称作为 key
|
||||
cleanName := strings.Trim(targetName, `"`)
|
||||
descMap[cleanName] = strings.TrimSpace(description)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 匹配文件名(不区分大小写,符合该标准的一贯做法)
|
||||
if strings.EqualFold(targetName, fileName) {
|
||||
// 部分实现会在描述末尾包含控制字符 \x04,需要清理
|
||||
description = strings.Split(description, "\x04")[0]
|
||||
return strings.TrimSpace(description)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return descMap
|
||||
}
|
||||
|
||||
// replyList 生成目录列表页面并返回
|
||||
@@ -188,25 +181,25 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
|
||||
files, err := os.ReadDir(fullPath)
|
||||
check(err)
|
||||
|
||||
// 只解析一次 DESCRIPT.ION,大幅提升性能
|
||||
descMap := parseDescriptIon(fullPath)
|
||||
|
||||
// 按文件名(不区分大小写)排序
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
nameI := files[i].Name()
|
||||
nameJ := files[j].Name()
|
||||
|
||||
// 1. 特殊逻辑:处理 DESCRIPT.ION
|
||||
// 如果 i 是目标文件,且 j 不是,则 i 应该在后 (返回 false)
|
||||
// DESCRIPT.ION 始终放在最后
|
||||
if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") {
|
||||
return false
|
||||
}
|
||||
// 如果 j 是目标文件,且 i 不是,则 i 应该在前 (返回 true)
|
||||
if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. 普通逻辑:不区分大小写的字母排序
|
||||
return strings.ToLower(nameI) < strings.ToLower(nameJ)
|
||||
return nameI < nameJ
|
||||
})
|
||||
// 确保路径以斜杠结尾
|
||||
// 确保路径以斜杠结尾
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path += "/"
|
||||
}
|
||||
@@ -228,43 +221,55 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
|
||||
p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", filepath.Base(fullPath), -1)
|
||||
}
|
||||
// 遍历目录中的每个条目
|
||||
for _, el := range files {
|
||||
info, errInfo := el.Info()
|
||||
el, err := os.Stat(fullPath + "/" + el.Name())
|
||||
if err != nil || errInfo != nil {
|
||||
log.Println("error - cant stat a file", err)
|
||||
// 遍历目录中的每个条目
|
||||
for _, entry := range files {
|
||||
name := entry.Name()
|
||||
|
||||
// 跳过隐藏文件
|
||||
if *skipHidden && strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过隐藏文件(如果配置了跳过)
|
||||
if *skipHidden && strings.HasPrefix(el.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
// 跳过符号链接(如果不允许跟随)
|
||||
if !*symlinks && info.Mode()&os.ModeSymlink != 0 {
|
||||
if !*symlinks && entry.Type()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// URL编码文件名, 避免特殊字符问题
|
||||
href := url.PathEscape(el.Name())
|
||||
name := el.Name()
|
||||
// 获取文件信息(只对文件调用 Info,文件夹不需要)
|
||||
var info fs.FileInfo
|
||||
if !entry.IsDir() {
|
||||
var statErr error
|
||||
info, statErr = entry.Info()
|
||||
if statErr != nil {
|
||||
log.Println("error - cant stat a file", statErr)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
href := url.PathEscape(name)
|
||||
// 处理文件夹链接的斜杠问题
|
||||
if el.IsDir() && strings.HasPrefix(href, "/") {
|
||||
if entry.IsDir() && strings.HasPrefix(href, "/") {
|
||||
href = strings.Replace(href, "/", "", 1)
|
||||
}
|
||||
|
||||
var desc string = lookupDesc(fullPath + "/" + el.Name())
|
||||
desc := descMap[name]
|
||||
|
||||
// 根据类型添加到不同的列表
|
||||
if el.IsDir() {
|
||||
row := rowTemplate{html.EscapeString(name + "/"), template.URL(href), "4.0kB", "folder", html.EscapeString(desc), "inode/directory"}
|
||||
if entry.IsDir() {
|
||||
row := rowTemplate{
|
||||
Name: html.EscapeString(name + "/"),
|
||||
Href: template.URL(href),
|
||||
Size: "4.0kB",
|
||||
Ext: "folder",
|
||||
Desc: html.EscapeString(desc),
|
||||
Mime: "inode/directory",
|
||||
}
|
||||
p.RowsFolders = append(p.RowsFolders, row)
|
||||
} else {
|
||||
// 提取文件扩展名
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".")
|
||||
// 文件:使用 info.Size()
|
||||
ext := strings.TrimPrefix(filepath.Ext(name), ".")
|
||||
filemime := mime.TypeByExtension("." + ext)
|
||||
filemime, _, _ = mime.ParseMediaType(filemime) // 避免mime瞎猜文档编码
|
||||
filemime, _, _ = mime.ParseMediaType(filemime)
|
||||
if filemime == "" {
|
||||
if name == "DESCRIPT.ION" {
|
||||
filemime = "text/plain"
|
||||
@@ -272,7 +277,15 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
|
||||
filemime = "unknown/unknown"
|
||||
}
|
||||
}
|
||||
row := rowTemplate{html.EscapeString(name), template.URL(href), humanize(el.Size()), html.EscapeString(ext), html.EscapeString(desc), filemime}
|
||||
|
||||
row := rowTemplate{
|
||||
Name: html.EscapeString(name),
|
||||
Href: template.URL(href),
|
||||
Size: humanize(info.Size()),
|
||||
Ext: html.EscapeString(ext),
|
||||
Desc: html.EscapeString(desc),
|
||||
Mime: filemime,
|
||||
}
|
||||
p.RowsFiles = append(p.RowsFiles, row)
|
||||
}
|
||||
}
|
||||
@@ -523,20 +536,19 @@ func rpc(w http.ResponseWriter, r *http.Request) {
|
||||
// p: 请求的路径
|
||||
// 返回: 安全的绝对路径
|
||||
func enforcePath(p string) string {
|
||||
// 构建完整路径并移除URL前缀
|
||||
joined := filepath.Join(rootPath, strings.TrimPrefix(p, *extraPath))
|
||||
fp, err := filepath.Abs(joined)
|
||||
sl, _ := filepath.EvalSymlinks(fp) // 评估符号链接的实际路径
|
||||
fp := filepath.Clean(joined)
|
||||
|
||||
// 安全检查:
|
||||
// 1. 获取绝对路径是否出错
|
||||
// 2. 路径是否以rootPath开头(防止路径遍历)
|
||||
// 3. 如果跳过隐藏文件, 路径中是否包含隐藏目录
|
||||
// 4. 如果不允许符号链接, 符号链接是否指向rootPath外
|
||||
if err != nil || !strings.HasPrefix(fp, rootPath) || *skipHidden && strings.Contains(p, "/.") || !*symlinks && len(sl) > 0 && !strings.HasPrefix(sl, rootPath) {
|
||||
if !strings.HasPrefix(fp, rootPath) || (*skipHidden && strings.Contains(p, "/.")) {
|
||||
panic(errors.New("invalid path"))
|
||||
}
|
||||
|
||||
if *symlinks {
|
||||
if sl, err := filepath.EvalSymlinks(fp); err == nil && !strings.HasPrefix(sl, rootPath) {
|
||||
panic(errors.New("invalid path"))
|
||||
}
|
||||
}
|
||||
|
||||
return fp
|
||||
}
|
||||
|
||||
@@ -566,7 +578,9 @@ func main() {
|
||||
// 注册路由处理器
|
||||
http.HandleFunc(*extraPath+"rpc", rpc) // RPC接口 (增删移与校验和)
|
||||
http.HandleFunc(*extraPath+"post", upload) // 文件上传接口
|
||||
http.HandleFunc(*extraPath+"dav", upload) // TODO: webdav 接口
|
||||
if *dav {
|
||||
http.HandleFunc(*extraPath+"dav/", webdavHandler) // WebDAV 接口
|
||||
}
|
||||
http.HandleFunc(*extraPath+"auth", authState) // 仅检查登录接口(不会弹窗)
|
||||
http.HandleFunc(*extraPath+"login", authLogin) // 登录接口
|
||||
http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口
|
||||
@@ -578,7 +592,7 @@ func main() {
|
||||
fmt.Printf("Agile File Interface 版本 %s\n", ver)
|
||||
fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n")
|
||||
fmt.Printf("AFI 已启动, 根目录为 %s\n", rootPath)
|
||||
fmt.Printf("详细输出: %t, 符号链接跟随: %t, 跳过隐藏文件: %t\n", *verb, *symlinks, *skipHidden)
|
||||
fmt.Printf("详细输出: %t, 符号链接跟随: %t, 跳过隐藏文件: %t, WebDAV: %t\n", *verb, *symlinks, *skipHidden, *dav)
|
||||
fmt.Printf("身份验证信息: %s\n", *authstr)
|
||||
fmt.Printf("正在监听: http://%s:%s%s\n", *host, *port, *extraPath)
|
||||
|
||||
|
||||
751
afi_webdav.go
Normal file
751
afi_webdav.go
Normal file
@@ -0,0 +1,751 @@
|
||||
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)
|
||||
}
|
||||
0
benchdir/00
Normal file
0
benchdir/00
Normal file
0
benchdir/01
Normal file
0
benchdir/01
Normal file
0
benchdir/02
Normal file
0
benchdir/02
Normal file
0
benchdir/03
Normal file
0
benchdir/03
Normal file
0
benchdir/04
Normal file
0
benchdir/04
Normal file
0
benchdir/05
Normal file
0
benchdir/05
Normal file
0
benchdir/06
Normal file
0
benchdir/06
Normal file
0
benchdir/07
Normal file
0
benchdir/07
Normal file
0
benchdir/08
Normal file
0
benchdir/08
Normal file
0
benchdir/09
Normal file
0
benchdir/09
Normal file
0
benchdir/10
Normal file
0
benchdir/10
Normal file
0
benchdir/11
Normal file
0
benchdir/11
Normal file
0
benchdir/12
Normal file
0
benchdir/12
Normal file
0
benchdir/13
Normal file
0
benchdir/13
Normal file
0
benchdir/14
Normal file
0
benchdir/14
Normal file
0
benchdir/15
Normal file
0
benchdir/15
Normal file
0
benchdir/16
Normal file
0
benchdir/16
Normal file
0
benchdir/17
Normal file
0
benchdir/17
Normal file
0
benchdir/18
Normal file
0
benchdir/18
Normal file
0
benchdir/19
Normal file
0
benchdir/19
Normal file
0
benchdir/20
Normal file
0
benchdir/20
Normal file
0
benchdir/21
Normal file
0
benchdir/21
Normal file
0
benchdir/22
Normal file
0
benchdir/22
Normal file
0
benchdir/23
Normal file
0
benchdir/23
Normal file
0
benchdir/24
Normal file
0
benchdir/24
Normal file
0
benchdir/25
Normal file
0
benchdir/25
Normal file
0
benchdir/26
Normal file
0
benchdir/26
Normal file
0
benchdir/27
Normal file
0
benchdir/27
Normal file
0
benchdir/28
Normal file
0
benchdir/28
Normal file
0
benchdir/29
Normal file
0
benchdir/29
Normal file
0
benchdir/30
Normal file
0
benchdir/30
Normal file
31
benchdir/DESCRIPT.ION
Normal file
31
benchdir/DESCRIPT.ION
Normal file
@@ -0,0 +1,31 @@
|
||||
00 Zeroth
|
||||
01 First
|
||||
02 Second
|
||||
03 Third
|
||||
04 Fourth
|
||||
05 Fifth
|
||||
06 Sixth
|
||||
07 Seventh
|
||||
08 Eighth
|
||||
09 Ninth
|
||||
10 Tenth
|
||||
11 Eleventh
|
||||
12 Twelfth
|
||||
13 Thirteenth
|
||||
14 Fourteenth
|
||||
15 Fifteenth
|
||||
16 Sixteenth
|
||||
17 Seventeenth
|
||||
18 Eighteenth
|
||||
19 Nineteenth
|
||||
20 Twentieth
|
||||
21 Twenty-first
|
||||
22 Twenty-second
|
||||
23 Twenty-third
|
||||
24 Twenty-fourth
|
||||
25 Twenty-fifth
|
||||
26 Twenty-sixth
|
||||
27 Twenty-seventh
|
||||
28 Twenty-eighth
|
||||
29 Twenty-ninth
|
||||
30 Thirtieth
|
||||
6
multiedit_demo.txt
Normal file
6
multiedit_demo.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
你好,世界!
|
||||
这是一个演示文件。
|
||||
我们正在使用 multiedit 同时修改多处。
|
||||
第一行
|
||||
第二行
|
||||
第三行
|
||||
BIN
screenshot/helppanel.png
Normal file
BIN
screenshot/helppanel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
BIN
screenshot/logged-in.png
Normal file
BIN
screenshot/logged-in.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
BIN
screenshot/login.png
Normal file
BIN
screenshot/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
screenshot/readonly.png
Normal file
BIN
screenshot/readonly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
@@ -30,7 +30,7 @@
|
||||
<a style="display: none;" onclick="afi.directcall.exec_mv()" class="operation">移动对象</a>
|
||||
<a style="display: none;" onclick="afi.directcall.exec_rm()" class="operation">递归删除</a>
|
||||
<a style="display: none;" onclick="afi.auth.logout()" class="operation">登出</a>
|
||||
<a style="display: block;" href="./login" target="_blank" rel="noopener noreferrer" class="operation">登录</a>
|
||||
<a style="display: block;" href="/login" target="_blank" rel="noopener noreferrer" class="operation">登录</a>
|
||||
<a style="user-select: none;" onclick="afi.utils.helpOn()" class="operation" id="help-toggle">帮助菜单</a>
|
||||
</div>
|
||||
</header>
|
||||
@@ -199,4 +199,4 @@
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user