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

194
afi.go
View File

@@ -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)