diff --git a/.gitignore b/.gitignore index c25678c..664047a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ afi-dev-windows.exe afi-dev.test *.out -.vscode \ No newline at end of file +.vscode + +AGENTS.md diff --git a/DESCRIPT.ION b/DESCRIPT.ION index 4061b35..3a3c806 100644 --- a/DESCRIPT.ION +++ b/DESCRIPT.ION @@ -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 自述文件 diff --git a/README.md b/README.md index 7a62719..3fb4184 100644 --- a/README.md +++ b/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 diff --git a/afi.go b/afi.go index 687cfc7..e274722 100644 --- a/afi.go +++ b/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) diff --git a/afi_webdav.go b/afi_webdav.go new file mode 100644 index 0000000..3e29a95 --- /dev/null +++ b/afi_webdav.go @@ -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) +} diff --git a/benchdir/00 b/benchdir/00 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/01 b/benchdir/01 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/02 b/benchdir/02 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/03 b/benchdir/03 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/04 b/benchdir/04 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/05 b/benchdir/05 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/06 b/benchdir/06 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/07 b/benchdir/07 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/08 b/benchdir/08 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/09 b/benchdir/09 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/10 b/benchdir/10 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/11 b/benchdir/11 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/12 b/benchdir/12 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/13 b/benchdir/13 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/14 b/benchdir/14 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/15 b/benchdir/15 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/16 b/benchdir/16 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/17 b/benchdir/17 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/18 b/benchdir/18 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/19 b/benchdir/19 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/20 b/benchdir/20 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/21 b/benchdir/21 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/22 b/benchdir/22 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/23 b/benchdir/23 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/24 b/benchdir/24 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/25 b/benchdir/25 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/26 b/benchdir/26 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/27 b/benchdir/27 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/28 b/benchdir/28 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/29 b/benchdir/29 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/30 b/benchdir/30 new file mode 100644 index 0000000..e69de29 diff --git a/benchdir/DESCRIPT.ION b/benchdir/DESCRIPT.ION new file mode 100644 index 0000000..99dd17b --- /dev/null +++ b/benchdir/DESCRIPT.ION @@ -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 diff --git a/go.mod b/go.mod index 59fffb6..e307d4c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module afi go 1.23.0 + +// Version 26.4.12 — see afi.go diff --git a/multiedit_demo.txt b/multiedit_demo.txt new file mode 100644 index 0000000..906cc67 --- /dev/null +++ b/multiedit_demo.txt @@ -0,0 +1,6 @@ +你好,世界! +这是一个演示文件。 +我们正在使用 multiedit 同时修改多处。 +第一行 +第二行 +第三行 diff --git a/screenshot/helppanel.png b/screenshot/helppanel.png new file mode 100644 index 0000000..c3354c7 Binary files /dev/null and b/screenshot/helppanel.png differ diff --git a/screenshot/logged-in.png b/screenshot/logged-in.png new file mode 100644 index 0000000..a3af756 Binary files /dev/null and b/screenshot/logged-in.png differ diff --git a/screenshot/login.png b/screenshot/login.png new file mode 100644 index 0000000..fc3b6e0 Binary files /dev/null and b/screenshot/login.png differ diff --git a/screenshot/readonly.png b/screenshot/readonly.png new file mode 100644 index 0000000..42e8cde Binary files /dev/null and b/screenshot/readonly.png differ diff --git a/ui/ui.html b/ui/ui.html index c91357a..18d8937 100644 --- a/ui/ui.html +++ b/ui/ui.html @@ -30,7 +30,7 @@ - 登录 + 登录 帮助菜单 @@ -199,4 +199,4 @@ - \ No newline at end of file +