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
.vscode
AGENTS.md

View File

@@ -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 自述文件

View File

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

156
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,30 +117,21 @@ 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 ""
}
if file, err := os.Open(descFilePath); err == nil {
defer file.Close()
// 3. 逐行扫描文件内容
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
@@ -149,16 +141,15 @@ func lookupDesc(fullPath string) string {
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:])
// 处理带双引号的文件名格式,例如:"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 {
@@ -166,15 +157,17 @@ func lookupDesc(fullPath string) string {
}
}
// 4. 匹配文件名(不区分大小写,符合该标准的一贯做法)
if strings.EqualFold(targetName, fileName) {
// 部分实现会在描述末尾包含控制字符 \x04需要清理
if targetName != "" {
// 清理可能的控制字符
description = strings.Split(description, "\x04")[0]
return strings.TrimSpace(description)
// 严格大小写:去掉可能的引号后,直接使用原始名称作为 key
cleanName := strings.Trim(targetName, `"`)
descMap[cleanName] = strings.TrimSpace(description)
}
}
}
return ""
return descMap
}
// replyList 生成目录列表页面并返回
@@ -188,23 +181,23 @@ 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, "/") {
@@ -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
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
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_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>