feat: 实现 WebDAV

This commit is contained in:
2026-04-26 04:45:48 +08:00
parent 41949ef7f4
commit 2112518f80
44 changed files with 912 additions and 106 deletions

2
.gitignore vendored
View File

@@ -17,3 +17,5 @@ afi-dev.test
*.out *.out
.vscode .vscode
AGENTS.md

View File

@@ -4,6 +4,7 @@ afi-dev 开发版本释出二进制
afi.go 主模块文件 afi.go 主模块文件
afi_embed.go 嵌入资源与模板配置 afi_embed.go 嵌入资源与模板配置
afi_embed_dev.go 热重载资源配置 afi_embed_dev.go 热重载资源配置
go.mod Golang 模块定义 go.mod Go/模块定义
benchdir 性能测试示例目录
LICENSE MIT 协议文件 LICENSE MIT 协议文件
README.md 自述文件 README.md 自述文件

View File

@@ -18,7 +18,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
- 基本的用户管理系统 (基于 HTTP Basic Authication), 我们移除了 Gossa 单一的只读/可写模式 (即 -ro 参数), 并保留了未登录状态下的默认只读模式 - 基本的用户管理系统 (基于 HTTP Basic Authication), 我们移除了 Gossa 单一的只读/可写模式 (即 -ro 参数), 并保留了未登录状态下的默认只读模式
- 支持跳过隐藏项目 (以 `.` 开头的文件/目录) - 支持跳过隐藏项目 (以 `.` 开头的文件/目录)
- 开发/生产模式分离, 便于免编译重载前端资源 - 开发/生产模式分离, 便于免编译重载前端资源
- 同时提供简易的 RPC 调用接口便于编程与 webdav 接口(TODO)便于挂载, 网页用户界面的 JavaScript 使用前者 - 同时提供简易的 RPC 调用接口, 并加入了 WebDAV 接口便于编程与挂载, 网页用户界面的 JavaScript 使用前者
- 低占用高性能, 网页用户界面每秒可承受万级以上响应并持续保持毫秒级低延迟, 接口性能更高 - 低占用高性能, 网页用户界面每秒可承受万级以上响应并持续保持毫秒级低延迟, 接口性能更高
- 默认启用 gzip 压缩, 并为 iframe 嵌入优化 - 默认启用 gzip 压缩, 并为 iframe 嵌入优化
- 性能高效, 提供毫秒级响应, 用户使用体验不让位于技术审美与性能 - 性能高效, 提供毫秒级响应, 用户使用体验不让位于技术审美与性能
@@ -26,10 +26,9 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
## 安全与不安全设计 ## 安全与不安全设计
考虑到此程序可能被使用的场景与便于方案选型, 笔者必须指出此程序存在的问题, 以免产生虚假的安全感 考虑到此程序可能被使用的场景与便于方案选型, 笔者在此指出此程序存在的问题, 以免产生虚假的安全感
如果您在为安全性抉择是否使用此项目, 我们高度建议您不要使用它 如果您在为安全性抉择是否使用此项目, 我们高度建议您不要使用它
如您发现了其他可能的漏洞, 请在 issues 指出, 若属实, 笔者会将其修复或加入下方列表, 并且将您的名字加入致谢名单 如您发现了其他可能的漏洞, 请在 issues 指出, 若属实, 笔者会将其修复或加入下方列表, 并且将您的名字加入致谢名单
注意: 请不要把"文档写出来了"等同于"只有这些问题"(我们当然追求安全审计文档的完善度)
安全建议: 安全建议:
@@ -49,7 +48,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
不安全设计: 不安全设计:
- 不能防范且无法容忍的攻击 - 不能防范且无法容忍的攻击
- 如上文所述, 这里没写不等于没有, 欢迎通过 issues 提出 - 没发现, 这里没写不等于没有, 欢迎通过 issues 提出
- 未防范但几乎不可能成功的攻击 - 未防范但几乎不可能成功的攻击
- Timing Attack (未防范, 密码在后端明文比较, 但可通过设置较不寻常的用户名规避) -> 我们未来大概率不会改 - Timing Attack (未防范, 密码在后端明文比较, 但可通过设置较不寻常的用户名规避) -> 我们未来大概率不会改
- 能防范的攻击 - 能防范的攻击
@@ -63,7 +62,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
- DoS 攻击 - 我们认为这件事应该由 nginx 反向代理预防 - DoS 攻击 - 我们认为这件事应该由 nginx 反向代理预防
- 请求大小和配额无限制导致的超大文件占用和可能导致的 OOM 崩溃 - 我们认为这件事应该由 nginx 反向代理或磁盘配额限制预防, 并且就事实而言, 内部威胁不在模型内, 这很大程度上是有权限的用户行为不当引起的问题 - 请求大小和配额无限制导致的超大文件占用和可能导致的 OOM 崩溃 - 我们认为这件事应该由 nginx 反向代理或磁盘配额限制预防, 并且就事实而言, 内部威胁不在模型内, 这很大程度上是有权限的用户行为不当引起的问题
- 无自带的响应超时: 可能招致 Slowloris 服务瘫痪 - 但这可由 nginx 反向代理预防 - 无自带的响应超时: 可能招致 Slowloris 服务瘫痪 - 但这可由 nginx 反向代理预防
- 权限提升风险: AFI 以什么用户运行, 就有什么权限 -- 如果以 root 运行且将分享目录设置为根目录, 则任何登录用户都能删除系统, 或者通过上传 `.ssh/authorized_keys``.bashrc` 远程控制您的服务器操作系统 -> 我们改不了 - 权限提升风险: AFI 以什么用户运行, 就有什么权限 - 如果以 root 运行且将分享目录设置为根目录, 则任何登录用户都能删除系统, 或者通过上传 `.ssh/authorized_keys``.bashrc` 远程控制您的服务器操作系统 -> 我们改不了
- 未默认杜绝的隐私问题: - 未默认杜绝的隐私问题:
- 外部字体 Referer 泄露 (仅会得知您访问的链接, 但如果您需要, 可以移除前端中引用的在线字体链接) 就事实而言, 我们认为外部字体提供商不太可能有收集这种数据的动机, 但如果您在高敏感内网部署, 则您可能不希望任何外部服务能够得知内网的文件结构 - 外部字体 Referer 泄露 (仅会得知您访问的链接, 但如果您需要, 可以移除前端中引用的在线字体链接) 就事实而言, 我们认为外部字体提供商不太可能有收集这种数据的动机, 但如果您在高敏感内网部署, 则您可能不希望任何外部服务能够得知内网的文件结构
- 日志可能泄漏敏感信息 (可以通过修改源代码编译解决, 但我们提供的二进制释出没有移除敏感信息输出) - 日志可能泄漏敏感信息 (可以通过修改源代码编译解决, 但我们提供的二进制释出没有移除敏感信息输出)
@@ -115,18 +114,18 @@ export GOBIN=$GOPATH/bin/
```text ```text
Test Platform: AMD Ryzen 3600X, tmpfs, 12 item with DESCRIPT.ION in directory Test Platform: AMD Ryzen 3600X, tmpfs, 12 item with DESCRIPT.ION in directory
OS: glibc linux-zen 6.19.10 OS: glibc linux-zen 6.19.10
Date: 2026-04-02 Date: 2026-04-02, 04-12
Version: latest Version: latest
Command: wrk -t8 -c50 -d15s http://addr/ Command: wrk -t8 -c50 -d15s http://addr/
Test Address: / Test Address: /
QPS Performace from test: QPS Performace from test:
Server QPS Data QPS Bar (higher is better) Gzip Page Server QPS Data QPS Bar (higher is better) Gzip Version Page
Nginx 60,242 QPS ████████████████████████████████ Disabled Default Index Nginx 60,242 QPS ████████████████████████████████ Disabled 1.28.3 Default Index
Apache 34,207 QPS █████████████████░░░░░░░░░░░░░░░ Disabled Default Index Apache 34,207 QPS █████████████████░░░░░░░░░░░░░░░ Disabled 2.4.66 Default Index
AFI 15,262 QPS ████████░░░░░░░░░░░░░░░░░░░░░░░░ BestSpeed Full-functional web file manager (without CSS & JS embedded in bench) (net/http) AFI 15,262 QPS ████████░░░░░░░░░░░░░░░░░░░░░░░░ BestSpeed 26.4.3 Web Interface (without CSS & JS embedded in bench) (net/http)
PyPy 4,169 QPS ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled Default Index (http.server) PyPy 4,169 QPS ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled 7.3.21 Default Index (http.server)
CPython 2,128 QPS █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled Default Index (http.server) CPython 2,128 QPS █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled 3.14.3 Default Index (http.server)
Latency from the same test: Latency from the same test:
Server Average Latency Bar (lower is better) Maximum Server Average Latency Bar (lower is better) Maximum

158
afi.go
View File

@@ -31,7 +31,7 @@ import (
"time" "time"
) )
const ver = "26.04.03" const ver = "26.4.26"
// rowTemplate 定义文件列表中的每一行数据结构 // rowTemplate 定义文件列表中的每一行数据结构
type rowTemplate struct { type rowTemplate struct {
@@ -64,6 +64,7 @@ var skipHidden = flag.Bool("k", false, "跳过隐藏文件")
// var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式 // var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式
var title = flag.String("title", "%PATH%", "页面标题, 用 %PATH% 指代完整路径, %ITEM% 指代末端文件/目录名, 不会泄露根目录目录名") var title = flag.String("title", "%PATH%", "页面标题, 用 %PATH% 指代完整路径, %ITEM% 指代末端文件/目录名, 不会泄露根目录目录名")
var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据 (也可以用环境变量 AFI_AUTH 设置, AFI_AUTH 是最优先且最安全的)") // 这个flag只能用来调试 不然服务器一个ps就泄露了 var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据 (也可以用环境变量 AFI_AUTH 设置, AFI_AUTH 是最优先且最安全的)") // 这个flag只能用来调试 不然服务器一个ps就泄露了
var dav = flag.Bool("dav", true, "启用 WebDAV 接口")
// rpcCall 定义RPC调用的JSON数据结构 // rpcCall 定义RPC调用的JSON数据结构
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和) // 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
@@ -116,30 +117,21 @@ func humanize(bytes int64) string {
} }
} }
// lookupDesc 返回指定文件在 DESCRIPT.ION 中的注释内容 // parseDescriptIon 只打开一次 DESCRIPT.ION 并返回 map大幅提升目录列表性能
func lookupDesc(fullPath string) string { func parseDescriptIon(dirPath string) map[string]string {
// 1. 获取绝对路径并提取目录和文件名 descMap := make(map[string]string)
absPath, err := filepath.Abs(fullPath) descFilePath := filepath.Join(dirPath, "DESCRIPT.ION")
if err != nil {
return ""
}
dir := filepath.Dir(absPath)
fileName := filepath.Base(absPath)
if fileName == "DESCRIPT.ION" { // 默认描述, 可被覆写
return "备注元数据" descMap["DESCRIPT.ION"] = "备注元数据"
} descMap["README.md"] = "自述文件"
descMap["LICENSE"] = "许可证"
descMap[".gitignore"] = "Git/文件忽略模式"
descMap[".git"] = "Git/裸仓库目录"
// 2. 尝试打开该目录下的 DESCRIPT.ION (不区分大小写) if file, err := os.Open(descFilePath); err == nil {
// 在 Windows 下直接打开即可,在 Linux 下可能需要遍历目录匹配文件名
descFilePath := filepath.Join(dir, "DESCRIPT.ION")
file, err := os.Open(descFilePath)
if err != nil {
return ""
}
defer file.Close() defer file.Close()
// 3. 逐行扫描文件内容
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
@@ -149,16 +141,15 @@ func lookupDesc(fullPath string) string {
var targetName, description string var targetName, description string
// DESCRIPT.ION 格式处理: // 处理带双引号的文件名格式,例如:"My Photo.jpg" This is a description
// 如果文件名包含空格,通常会被双引号包裹,例如: "My Photo.jpg" This is a photo if strings.HasPrefix(line, `"`) {
if strings.HasPrefix(line, "\"") { end := strings.Index(line[1:], `"`)
endQuoteIndex := strings.Index(line[1:], "\"") if end != -1 {
if endQuoteIndex != -1 { targetName = line[1 : end+1]
targetName = line[1 : endQuoteIndex+1] description = strings.TrimSpace(line[end+2:])
description = strings.TrimSpace(line[endQuoteIndex+2:])
} }
} else { } else {
// 普通格式:文件名 描述 (空格分隔) // 普通格式:filename description
parts := strings.SplitN(line, " ", 2) parts := strings.SplitN(line, " ", 2)
targetName = parts[0] targetName = parts[0]
if len(parts) > 1 { if len(parts) > 1 {
@@ -166,15 +157,17 @@ func lookupDesc(fullPath string) string {
} }
} }
// 4. 匹配文件名(不区分大小写,符合该标准的一贯做法) if targetName != "" {
if strings.EqualFold(targetName, fileName) { // 清理可能的控制字符
// 部分实现会在描述末尾包含控制字符 \x04需要清理
description = strings.Split(description, "\x04")[0] description = strings.Split(description, "\x04")[0]
return strings.TrimSpace(description) // 严格大小写:去掉可能的引号后,直接使用原始名称作为 key
cleanName := strings.Trim(targetName, `"`)
descMap[cleanName] = strings.TrimSpace(description)
}
} }
} }
return "" return descMap
} }
// replyList 生成目录列表页面并返回 // replyList 生成目录列表页面并返回
@@ -188,25 +181,25 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
files, err := os.ReadDir(fullPath) files, err := os.ReadDir(fullPath)
check(err) check(err)
// 只解析一次 DESCRIPT.ION大幅提升性能
descMap := parseDescriptIon(fullPath)
// 按文件名(不区分大小写)排序 // 按文件名(不区分大小写)排序
sort.Slice(files, func(i, j int) bool { sort.Slice(files, func(i, j int) bool {
nameI := files[i].Name() nameI := files[i].Name()
nameJ := files[j].Name() nameJ := files[j].Name()
// 1. 特殊逻辑:处理 DESCRIPT.ION // DESCRIPT.ION 始终放在最后
// 如果 i 是目标文件,且 j 不是,则 i 应该在后 (返回 false)
if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") { if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") {
return false return false
} }
// 如果 j 是目标文件,且 i 不是,则 i 应该在前 (返回 true)
if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") { if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") {
return true return true
} }
// 2. 普通逻辑:不区分大小写的字母排序 return nameI < nameJ
return strings.ToLower(nameI) < strings.ToLower(nameJ)
}) })
// 确保路径以斜杠结尾 // 确保路径以斜杠结尾
if !strings.HasSuffix(path, "/") { if !strings.HasSuffix(path, "/") {
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) p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", filepath.Base(fullPath), -1)
} }
// 遍历目录中的每个条目 // 遍历目录中的每个条目
for _, el := range files { // 遍历目录中的每个条目
info, errInfo := el.Info() for _, entry := range files {
el, err := os.Stat(fullPath + "/" + el.Name()) name := entry.Name()
if err != nil || errInfo != nil {
log.Println("error - cant stat a file", err) // 跳过隐藏文件
if *skipHidden && strings.HasPrefix(name, ".") {
continue continue
} }
// 跳过隐藏文件(如果配置了跳过)
if *skipHidden && strings.HasPrefix(el.Name(), ".") {
continue
}
// 跳过符号链接(如果不允许跟随) // 跳过符号链接(如果不允许跟随)
if !*symlinks && info.Mode()&os.ModeSymlink != 0 { if !*symlinks && entry.Type()&os.ModeSymlink != 0 {
continue continue
} }
// URL编码文件名, 避免特殊字符问题 // 获取文件信息(只对文件调用 Info文件夹不需要
href := url.PathEscape(el.Name()) var info fs.FileInfo
name := el.Name() 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) href = strings.Replace(href, "/", "", 1)
} }
var desc string = lookupDesc(fullPath + "/" + el.Name()) desc := descMap[name]
// 根据类型添加到不同的列表 // 根据类型添加到不同的列表
if el.IsDir() { if entry.IsDir() {
row := rowTemplate{html.EscapeString(name + "/"), template.URL(href), "4.0kB", "folder", html.EscapeString(desc), "inode/directory"} 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) p.RowsFolders = append(p.RowsFolders, row)
} else { } else {
// 提取文件扩展名 // 文件:使用 info.Size()
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") ext := strings.TrimPrefix(filepath.Ext(name), ".")
filemime := mime.TypeByExtension("." + ext) filemime := mime.TypeByExtension("." + ext)
filemime, _, _ = mime.ParseMediaType(filemime) // 避免mime瞎猜文档编码 filemime, _, _ = mime.ParseMediaType(filemime)
if filemime == "" { if filemime == "" {
if name == "DESCRIPT.ION" { if name == "DESCRIPT.ION" {
filemime = "text/plain" filemime = "text/plain"
@@ -272,7 +277,15 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
filemime = "unknown/unknown" 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) p.RowsFiles = append(p.RowsFiles, row)
} }
} }
@@ -523,20 +536,19 @@ func rpc(w http.ResponseWriter, r *http.Request) {
// p: 请求的路径 // p: 请求的路径
// 返回: 安全的绝对路径 // 返回: 安全的绝对路径
func enforcePath(p string) string { func enforcePath(p string) string {
// 构建完整路径并移除URL前缀
joined := filepath.Join(rootPath, strings.TrimPrefix(p, *extraPath)) joined := filepath.Join(rootPath, strings.TrimPrefix(p, *extraPath))
fp, err := filepath.Abs(joined) fp := filepath.Clean(joined)
sl, _ := filepath.EvalSymlinks(fp) // 评估符号链接的实际路径
// 安全检查: if !strings.HasPrefix(fp, rootPath) || (*skipHidden && strings.Contains(p, "/.")) {
// 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) {
panic(errors.New("invalid path")) 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 return fp
} }
@@ -566,7 +578,9 @@ func main() {
// 注册路由处理器 // 注册路由处理器
http.HandleFunc(*extraPath+"rpc", rpc) // RPC接口 (增删移与校验和) http.HandleFunc(*extraPath+"rpc", rpc) // RPC接口 (增删移与校验和)
http.HandleFunc(*extraPath+"post", upload) // 文件上传接口 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+"auth", authState) // 仅检查登录接口(不会弹窗)
http.HandleFunc(*extraPath+"login", authLogin) // 登录接口 http.HandleFunc(*extraPath+"login", authLogin) // 登录接口
http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口 http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口
@@ -578,7 +592,7 @@ func main() {
fmt.Printf("Agile File Interface 版本 %s\n", ver) fmt.Printf("Agile File Interface 版本 %s\n", ver)
fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n") fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n")
fmt.Printf("AFI 已启动, 根目录为 %s\n", rootPath) 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("身份验证信息: %s\n", *authstr)
fmt.Printf("正在监听: http://%s:%s%s\n", *host, *port, *extraPath) fmt.Printf("正在监听: http://%s:%s%s\n", *host, *port, *extraPath)

751
afi_webdav.go Normal file
View 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
View File

0
benchdir/01 Normal file
View File

0
benchdir/02 Normal file
View File

0
benchdir/03 Normal file
View File

0
benchdir/04 Normal file
View File

0
benchdir/05 Normal file
View File

0
benchdir/06 Normal file
View File

0
benchdir/07 Normal file
View File

0
benchdir/08 Normal file
View File

0
benchdir/09 Normal file
View File

0
benchdir/10 Normal file
View File

0
benchdir/11 Normal file
View File

0
benchdir/12 Normal file
View File

0
benchdir/13 Normal file
View File

0
benchdir/14 Normal file
View File

0
benchdir/15 Normal file
View File

0
benchdir/16 Normal file
View File

0
benchdir/17 Normal file
View File

0
benchdir/18 Normal file
View File

0
benchdir/19 Normal file
View File

0
benchdir/20 Normal file
View File

0
benchdir/21 Normal file
View File

0
benchdir/22 Normal file
View File

0
benchdir/23 Normal file
View File

0
benchdir/24 Normal file
View File

0
benchdir/25 Normal file
View File

0
benchdir/26 Normal file
View File

0
benchdir/27 Normal file
View File

0
benchdir/28 Normal file
View File

0
benchdir/29 Normal file
View File

0
benchdir/30 Normal file
View File

31
benchdir/DESCRIPT.ION Normal file
View 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

2
go.mod
View File

@@ -1,3 +1,5 @@
module afi module afi
go 1.23.0 go 1.23.0
// Version 26.4.12 — see afi.go

6
multiedit_demo.txt Normal file
View File

@@ -0,0 +1,6 @@
你好,世界!
这是一个演示文件。
我们正在使用 multiedit 同时修改多处。
第一行
第二行
第三行

BIN
screenshot/helppanel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

BIN
screenshot/logged-in.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
screenshot/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
screenshot/readonly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -30,7 +30,7 @@
<a style="display: none;" onclick="afi.directcall.exec_mv()" class="operation">移动对象</a> <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.directcall.exec_rm()" class="operation">递归删除</a>
<a style="display: none;" onclick="afi.auth.logout()" 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> <a style="user-select: none;" onclick="afi.utils.helpOn()" class="operation" id="help-toggle">帮助菜单</a>
</div> </div>
</header> </header>