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