feat: 实现 WebDAV
This commit is contained in:
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user