Compare commits

...

2 Commits

Author SHA1 Message Date
1140ffc61f perf: 增加内存缓存提升性能 2026-04-26 05:12:35 +08:00
2112518f80 feat: 实现 WebDAV 2026-04-26 04:45:48 +08:00
45 changed files with 1071 additions and 1056 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,27 +114,28 @@ 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 21,914 QPS ███████████░░░░░░░░░░░░░░░░░░░░░ BestSpeed 26.4.26 Web Interface (without CSS & JS embedded in bench) (net/http)
AFI(old) 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
Nginx 0.91ms █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 14.49ms
Apache 1.43ms ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 41.05ms
AFI 4.69ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 67.67ms
CPython 18.78ms ████████████████████░░░░░░░░░░░░ 1.67s
PyPy 23.86ms ██████████████████████████░░░░░░ 1.67s
Server Average Latency Bar (lower is better) Maximum
Nginx 0.91ms █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 14.49ms ▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
Apache 1.43ms ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 41.05ms ▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
AFI 26.4.26 3.01ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 36.24ms ▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
AFI 26.4.3 4.69ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 67.67ms ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
CPython 18.78ms ████████████████████░░░░░░░░░░░░ 1.67s ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓...
PyPy 23.86ms ██████████████████████████░░░░░░ 1.67s ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓...
Socket Errors from the same test:
Server Number (lower is better)
Nginx 0

429
afi.go
View File

@@ -3,6 +3,7 @@ package main
import (
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"crypto/md5"
"crypto/sha1"
@@ -28,10 +29,11 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
)
const ver = "26.04.03"
const ver = "26.4.26"
// rowTemplate 定义文件列表中的每一行数据结构
type rowTemplate struct {
@@ -64,6 +66,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", false, "启用 WebDAV 接口")
// rpcCall 定义RPC调用的JSON数据结构
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
@@ -73,9 +76,32 @@ type rpcCall struct {
}
var rootPath = "" // 共享目录的根路径(文件系统中的绝对路径)
var handler http.Handler // HTTP处理器, 用于处理静态文件服务
var authTable = make(map[string]string)
// gzipWriterPool 复用 gzip.Writer减少每次请求的分配开销
var gzipWriterPool = sync.Pool{
New: func() any {
w, err := gzip.NewWriterLevel(nil, gzip.BestSpeed)
if err != nil {
panic(err)
}
return w
},
}
// dirCacheEntry 缓存目录列表数据,避免重复 ReadDir + Stat + 排序
type dirCacheEntry struct {
etag string // ETag 值
folders []rowTemplate // 文件夹列表(不含 "../"
files []rowTemplate // 文件列表
mtime time.Time // 目录修改时间(用于验证)
}
var (
dirCacheMu sync.RWMutex
dirCache = make(map[string]*dirCacheEntry, 128)
)
// check 检查错误, 如果错误不为nil则panic
// 用于简化错误处理
func check(e error) {
@@ -88,7 +114,7 @@ func check(e error) {
// 用于捕获panic并返回500错误, 同时记录日志
// w: HTTP响应写入器
// s: 日志信息参数
func exitPath(w http.ResponseWriter, s ...interface{}) {
func exitPath(w http.ResponseWriter, s ...any) {
if r := recover(); r != nil {
// 发生panic时记录错误并返回500状态码
log.Println("error", s, r)
@@ -103,78 +129,72 @@ func exitPath(w http.ResponseWriter, s ...interface{}) {
// 例如: 1024 -> "1.0k", 1048576 -> "1.0M"
// bytes: 文件大小(字节)
// 返回: 格式化后的字符串(如"1.5M"
var humanUnits = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
func humanize(bytes int64) string {
b := float64(bytes)
u := 0
for {
if b < 1024 {
// 根据当前数量级选择合适的单位
return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}[u]
}
b = b / 1024
if bytes < 1024 {
return strconv.FormatInt(bytes, 10) + "B"
}
f := float64(bytes) / 1024
u := 1
for f >= 1024 && u < len(humanUnits)-1 {
f /= 1024
u++
}
return strconv.FormatFloat(f, 'f', 1, 64) + humanUnits[u]
}
// 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 生成目录列表页面并返回
@@ -183,111 +203,174 @@ func lookupDesc(fullPath string) string {
// r: HTTP请求
// fullPath: 目录的绝对路径
// path: 请求的URL路径
func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string) {
// 读取目录内容
files, err := os.ReadDir(fullPath)
check(err)
// dirFi: 目录的 FileInfo由调用者提供避免重复 stat
func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string, dirFi fs.FileInfo) {
dirMtime := dirFi.ModTime()
// ETag 基于目录 mtime目录内容变化时自动失效
etag := fmt.Sprintf(`W/"%x"`, dirMtime.UnixNano())
// 按文件名(不区分大小写)排序
sort.Slice(files, func(i, j int) bool {
nameI := files[i].Name()
nameJ := files[j].Name()
// 缓存查找
dirCacheMu.RLock()
entry, ok := dirCache[fullPath]
dirCacheMu.RUnlock()
// 1. 特殊逻辑:处理 DESCRIPT.ION
// 如果 i 是目标文件,且 j 不是,则 i 应该在后 (返回 false)
if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") {
return false
var folders, files []rowTemplate
if ok && entry.mtime.Equal(dirMtime) {
// 缓存命中 → ETag 检查
if r.Header.Get("If-None-Match") == entry.etag {
w.WriteHeader(http.StatusNotModified)
return
}
// 如果 j 是目标文件,且 i 不是,则 i 应该在前 (返回 true)
if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") {
return true
w.Header().Set("ETag", entry.etag)
folders = entry.folders
files = entry.files
} else {
// 缓存未命中 → 读取目录并构建
entries, err := os.ReadDir(fullPath)
check(err)
descMap := parseDescriptIon(fullPath)
sort.Slice(entries, func(i, j int) bool {
nameI := entries[i].Name()
nameJ := entries[j].Name()
if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") {
return false
}
if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") {
return true
}
return nameI < nameJ
})
mimeCache := make(map[string]string, 16)
folders = make([]rowTemplate, 0, len(entries))
files = make([]rowTemplate, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if *skipHidden && strings.HasPrefix(name, ".") {
continue
}
if !*symlinks && entry.Type()&os.ModeSymlink != 0 {
continue
}
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 entry.IsDir() && strings.HasPrefix(href, "/") {
href = strings.TrimPrefix(href, "/")
}
desc := descMap[name]
if entry.IsDir() {
folders = append(folders, rowTemplate{
Name: html.EscapeString(name + "/"),
Href: template.URL(href),
Size: "4.0kB",
Ext: "folder",
Desc: html.EscapeString(desc),
Mime: "inode/directory",
})
} else {
ext := strings.TrimPrefix(filepath.Ext(name), ".")
filemime, ok := mimeCache[ext]
if !ok {
filemime = mime.TypeByExtension("." + ext)
if filemime != "" {
if idx := strings.IndexByte(filemime, ';'); idx >= 0 {
filemime = strings.TrimSpace(filemime[:idx])
}
} else {
if name == "DESCRIPT.ION" {
filemime = "text/plain"
} else {
filemime = "unknown/unknown"
}
}
mimeCache[ext] = filemime
}
files = append(files, rowTemplate{
Name: html.EscapeString(name),
Href: template.URL(href),
Size: humanize(info.Size()),
Ext: html.EscapeString(ext),
Desc: html.EscapeString(desc),
Mime: filemime,
})
}
}
// 2. 普通逻辑:不区分大小写的字母排序
return strings.ToLower(nameI) < strings.ToLower(nameJ)
})
// 确保路径以斜杠结尾
// 入缓存
dirCacheMu.Lock()
dirCache[fullPath] = &dirCacheEntry{etag: etag, folders: folders, files: files, mtime: dirMtime}
dirCacheMu.Unlock()
w.Header().Set("ETag", etag)
}
// --- 构建页面(请求相关部分)---
if !strings.HasSuffix(path, "/") {
path += "/"
}
// 构建当前路径
pathtext := "/" + strings.TrimPrefix(path, *extraPath) // 删除extraPath前缀
pathtext := "/" + strings.TrimPrefix(path, *extraPath)
p := pageTemplate{}
now := time.Now().UTC()
p.TimeStamp = now.Format(time.RFC3339)
// 如果不是根目录, 添加返回上级目录的链接
rowCap := len(folders) + len(files) + 1
p.RowsFolders = make([]rowTemplate, 0, rowCap)
p.RowsFiles = make([]rowTemplate, 0, rowCap)
if path != *extraPath {
p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"})
}
p.RowsFolders = append(p.RowsFolders, folders...)
p.RowsFiles = append(p.RowsFiles, files...)
p.ExtraPath = template.HTML(html.EscapeString(*extraPath))
p.Path = template.HTML(html.EscapeString(pathtext)) // 安全化路径
p.Path = template.HTML(html.EscapeString(pathtext))
if pathtext == "/" {
p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", "/", -1)
p.Title = strings.ReplaceAll(strings.ReplaceAll(*title, "%PATH%", pathtext), "%ITEM%", "/")
} else {
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)
continue
}
// 跳过隐藏文件(如果配置了跳过)
if *skipHidden && strings.HasPrefix(el.Name(), ".") {
continue
}
// 跳过符号链接(如果不允许跟随)
if !*symlinks && info.Mode()&os.ModeSymlink != 0 {
continue
}
// URL编码文件名, 避免特殊字符问题
href := url.PathEscape(el.Name())
name := el.Name()
// 处理文件夹链接的斜杠问题
if el.IsDir() && strings.HasPrefix(href, "/") {
href = strings.Replace(href, "/", "", 1)
}
var desc string = lookupDesc(fullPath + "/" + el.Name())
// 根据类型添加到不同的列表
if el.IsDir() {
row := rowTemplate{html.EscapeString(name + "/"), template.URL(href), "4.0kB", "folder", html.EscapeString(desc), "inode/directory"}
p.RowsFolders = append(p.RowsFolders, row)
} else {
// 提取文件扩展名
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".")
filemime := mime.TypeByExtension("." + ext)
filemime, _, _ = mime.ParseMediaType(filemime) // 避免mime瞎猜文档编码
if filemime == "" {
if name == "DESCRIPT.ION" {
filemime = "text/plain"
} else {
filemime = "unknown/unknown"
}
}
row := rowTemplate{html.EscapeString(name), template.URL(href), humanize(el.Size()), html.EscapeString(ext), html.EscapeString(desc), filemime}
p.RowsFiles = append(p.RowsFiles, row)
}
p.Title = strings.ReplaceAll(strings.ReplaceAll(*title, "%PATH%", pathtext), "%ITEM%", filepath.Base(fullPath))
}
// 检查客户端是否支持gzip压缩
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Type", "text/html")
// 渲染到 buffer按大小决定是否 gzip<1KB 直接发,压缩了更慢)
var buf bytes.Buffer
tmpl.Execute(&buf, p)
body := buf.Bytes()
w.Header().Set("Content-Type", "text/html")
if len(body) > 1024 && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Add("Content-Encoding", "gzip")
// 使用gzip压缩响应内容, 提高传输效率
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
check(err)
defer gz.Close()
tmpl.Execute(gz, p)
gz := gzipWriterPool.Get().(*gzip.Writer)
gz.Reset(w)
gz.Write(body)
gz.Close()
gzipWriterPool.Put(gz)
} else {
tmpl.Execute(w, p)
w.Write(body)
}
}
@@ -296,8 +379,6 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
// w: HTTP响应写入器
// r: HTTP请求
func doContent(w http.ResponseWriter, r *http.Request) {
// query := r.URL.Query()
// 此处可以接收参数
// 如果URL不以配置的前缀开头, 重定向到正确的前缀
if !strings.HasPrefix(r.URL.Path, *extraPath) {
http.Redirect(w, r, *extraPath, http.StatusFound)
@@ -309,14 +390,20 @@ func doContent(w http.ResponseWriter, r *http.Request) {
defer exitPath(w, "get content", path)
// 获取安全的绝对路径
fullPath := enforcePath(path)
stat, errStat := os.Stat(fullPath)
check(errStat)
// 如果是目录, 显示文件列表;如果是文件, 交给静态文件服务器处理
if stat.IsDir() {
replyList(w, r, fullPath, path)
// 只 Open + Stat 一次,避免 FileServer 内部二次 stat
f, err := os.Open(fullPath)
check(err)
fi, err := f.Stat()
check(err)
if fi.IsDir() {
f.Close()
replyList(w, r, fullPath, path, fi)
} else {
handler.ServeHTTP(w, r)
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(fi.Name())))
http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
f.Close()
}
}
@@ -449,8 +536,8 @@ func zipRPC(w http.ResponseWriter, r *http.Request) {
// 打开文件并复制到ZIP中
file, err := os.Open(path)
check(err)
defer file.Close()
_, err = io.Copy(headerWriter, file)
file.Close()
check(err)
return nil
})
@@ -523,20 +610,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
}
@@ -561,24 +647,31 @@ func main() {
check(err)
// 创建HTTP服务器
server := &http.Server{Addr: *host + ":" + *port, Handler: handler}
server := &http.Server{
Addr: *host + ":" + *port,
Handler: nil,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 注册路由处理器
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打包下载接口
http.HandleFunc("/", doContent) // 主内容处理接口
// 创建静态文件服务器, 用于直接提供文件下载
handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath)))
// 输出启动信息
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

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

@@ -1,478 +0,0 @@
afi = {
auth: {},
consts: {},
params: {},
elements: {},
utils: {},
rpc: {},
uploader: {},
interface: {},
directcall: {},
}
afi.auth = {
isLoggedIn: false,
checkAuth: async function () {
try {
const res = await fetch(location.origin + afi.params.extraPath + '/auth', {
method: 'GET',
credentials: 'include'
});
this.isLoggedIn = (res.status === 200);
return this.isLoggedIn;
} catch (e) {
this.isLoggedIn = false;
return false;
}
},
logout: function () {
fetch(location.origin + afi.params.extraPath + '/auth', {
method: 'GET',
headers: { 'Authorization': 'Basic ' + btoa('invalid:invalid') },
credentials: 'include'
});
this.isLoggedIn = false;
location.reload();
}
}
afi.consts = {
leftPageWarn: "确认放弃正在进行的传输?",
ensureDrop: () => !confirm('确认移动此对象?')
}
afi.params = {
extraPath: extraPath
}
afi.elements = {
itemlinks: Array.from(document.querySelectorAll('a.item-links')),
table: document.getElementById("index-table"),
badge: document.getElementById("badge"),
uploader: document.getElementById("clickupload"),
path: document.getElementById("path"),
nav: document.getElementById("nav"),
title: document.head.querySelector("title"),
dropGrid: document.getElementById("drop-grid"),
barDisplay: document.getElementById("bar-display"),
barProc: document.getElementById("bar-proc"),
helpPanel: document.getElementById('help-panel'),
helpToggle: document.getElementById('help-toggle'),
}
afi.pulsar = {
pulseLeft: 0,
pulseNotificator(text) {
afi.elements.badge.innerText = text
this.pulseEffect(afi.elements.badge)
},
pulseSuccess() {
this.pulseNotificator("操作成功")
},
pulseFailure(error) {
this.pulseNotificator("操作失败 " + error)
},
pulseEffect: function (element) {
element.style.display = "block"
afi.pulsar.pulseLeft += 1
element.classList.remove('pulse');
void element.offsetWidth;
element.classList.add('pulse');
setTimeout(function () {
afi.pulsar.pulseLeft -= 1;
if (afi.pulsar.pulseLeft > 0) {
console.log("[AFI] Notification stack", afi.pulsar.pulseLeft, "left, skip clear")
} else {
console.log("[AFI] Notification stack", afi.pulsar.pulseLeft, "left, do clear")
element.style.display = 'none';
}
}, 5000);
},
}
afi.utils = {
isDupe: function (itemname) {
if (afi.elements.itemlinks.find(a => a.innerText.replace('/', '') === itemname)) {
alert('名称 ' + itemname + ' 已被已存在的项目占用');
return true
}
else {
return false
}
},
isFolder: function (element) {
return element && element.href && element.innerText.endsWith('/');
},
encodeURIHash: function (e) {
return encodeURI(e).replaceAll('#', '%23');
},
refresh: function () {
afi.interface.browseTo(location.href, true);
},
prependPath: function (a) {
return a.startsWith('/') ? a : decodeURI(location.pathname) + a;
},
cancelDefault(e) {
e.preventDefault()
e.stopPropagation()
},
setHighlight: function (t) {
t.classList.add('highlight')
},
getHighlight: function () {
return document.querySelector('.highlight') || {}
},
resetHighlight: function () {
try {
this.getHighlight().classList.remove('highlight')
} catch (e) {
}
},
isHelpMode: () => afi.elements.helpPanel.style.display === 'block',
helpOn: function () {
afi.elements.helpPanel.style.display = 'block'
afi.elements.table.style.display = 'none'
afi.elements.helpToggle.innerText = "关闭帮助"
afi.elements.helpToggle.onclick = afi.utils.helpOff
},
helpOff: function () {
if (!afi.utils.isHelpMode()) return
afi.elements.helpPanel.style.display = 'none'
afi.elements.table.style.display = 'table'
afi.elements.helpToggle.innerText = "帮助菜单"
afi.elements.helpToggle.onclick = afi.utils.helpOn
},
updateButtons: function () {
const isLogged = afi.auth.isLoggedIn;
document.querySelectorAll('#operations a').forEach(link => {
const text = link.innerText.trim();
if (text === '登录') {
link.style.display = isLogged ? 'none' : 'block';
} else if (['上传文件', '新建文件', '新建目录', '移动对象', '递归删除', '登出'].includes(text)) {
link.style.display = isLogged ? 'block' : 'none';
if (text === '登出' && isLogged) {
link.onclick = function (e) {
e.preventDefault();
afi.auth.logout();
return false;
};
}
}
});
},
}
afi.rpc = {
rpc: function (call, args, cb) {
console.log('[AFI] RPC', call, args);
const xhr = new XMLHttpRequest();
xhr.open('POST', location.origin + afi.params.extraPath + '/rpc');
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xhr.withCredentials = true;
xhr.send(JSON.stringify({ call, args }));
xhr.onload = cb;
xhr.onerror = () => afi.pulsar.pulseFailure();
},
mkdirCall: function (path, cb) {
this.rpc('mkdirp', [afi.utils.prependPath(path)], cb);
},
rmCall: function (path1, cb) {
this.rpc('rm', [afi.utils.prependPath(path1)], cb);
},
mvCall: function (path1, path2, cb) {
this.rpc('mv', [path1, path2], cb);
},
sumCall: function (path, type, cb) {
this.rpc('sum', [afi.utils.prependPath(path), type], cb);
},
touchCall: function (path, cb) {
this.rpc('touch', [afi.utils.prependPath(path)], cb);
}
}
afi.uploader = {
totalUploads: 0,
totalUploaded: 0,
totalUploadsSize: 0,
totalUploadedSize: [],
shouldRefresh: function () {
this.totalUploaded += 1
console.log('[AFI] Have uploaded ' + this.totalUploaded + ' files')
if (this.totalUploads === this.totalUploaded) {
window.onbeforeunload = null
console.log('[AFI] Finished after uploaded ' + this.totalUploaded + ' files')
this.totalUploaded = 0
this.totalUploads = 0
this.totalUploadsSize = 0
this.totalUploadedSize = []
setTimeout(afi.utils.refresh, 200)
afi.elements.barDisplay.style.display = 'none'
}
},
updatePercent: function (event) {
this.totalUploadedSize[event.target.id] = event.loaded
const ttlDone = this.totalUploadedSize.reduce((s, x) => s + x)
const percent = Math.min(Math.floor(100 * ttlDone / this.totalUploadsSize), 100) + '%'
afi.elements.barProc.innerText = "传输: " + percent
},
upload: function (id, what, path, cbDone, cbErr, cbUpdate) {
const xhr = new XMLHttpRequest();
xhr.open('POST', location.origin + afi.params.extraPath + '/post');
xhr.setRequestHeader('afi-path', path);
xhr.withCredentials = true;
xhr.upload.addEventListener('load', cbDone);
xhr.upload.addEventListener('progress', cbUpdate);
xhr.upload.addEventListener('error', cbErr);
xhr.upload.id = id;
xhr.send(what);
},
uploadFile: function (file, path) {
path = decodeURI(location.pathname).slice(0, -1) + path
window.onbeforeunload = afi.consts.leftPageWarn
afi.elements.barProc.style.display = afi.elements.barDisplay.style.display = 'block'
this.totalUploads += 1
this.totalUploadsSize += file.size
if (typeof upBarName !== 'undefined') {
upBarName.innerText = this.totalUploads > 1 ? this.totalUploads + ' 个文件' : file.name
}
const formData = new FormData()
formData.append(file.name, file)
this.upload(this.totalUploads, formData, encodeURIComponent(path), this.shouldRefresh.bind(this), null, this.updatePercent.bind(this))
}
}
afi.interface = {
pushEntry: function (entry) {
if (!entry.webkitGetAsEntry && !entry.getAsEntry) {
return alert('不兼容此浏览器')
} else {
entry = entry.webkitGetAsEntry() || entry.getAsEntry()
}
this.parseDomItem.bind(this)(entry, true)
},
parseDomFolder: function (f) {
f.createReader().readEntries(e => e.forEach(i => afi.interface.parseDomItem.bind(this)(i)))
},
parseDomItem: function (domFile, shouldCheckDupes) {
if (shouldCheckDupes && afi.utils.isDupe(domFile.name)) {
return
}
if (domFile.isFile) {
domFile.file(f => afi.uploader.uploadFile(f, domFile.fullPath))
} else {
const f = domFile.fullPath.startsWith('/') ? domFile.fullPath.slice(1) : domFile.fullPath
afi.rpc.mkdirCall(f, () => this.parseDomFolder.bind(this)(domFile))
}
},
browseTo: async function (href, pulseEffectDone, skipHistory) {
try {
const r = await fetch(href, { credentials: 'include' })
const t = await r.text()
const parsed = new DOMParser().parseFromString(t, 'text/html')
afi.elements.table.innerHTML = parsed.getElementById('index-table').innerHTML
const nav = parsed.getElementById('nav').innerText
const path = parsed.getElementById('path').innerText
const title = parsed.head.querySelector('title').innerText
if (afi.elements.path.innerText !== path) {
if (!skipHistory) {
const escaped = afi.utils.encodeURIHash(afi.params.extraPath + path)
history.pushState({}, '', escaped)
}
afi.elements.title.innerText = title
afi.elements.nav.innerText = nav
afi.elements.path.innerText = path
this.setBreadcrumbs()
}
init()
if (pulseEffectDone) afi.pulsar.pulseSuccess()
} catch (error) {
afi.pulsar.pulseFailure(error)
}
},
setBreadcrumbs: function () {
const parent = afi.elements.nav.parentNode;
afi.elements.nav.outerHTML = '<span id="nav" onclick="return afi.directcall.bread_click(event)"><span class="nav">' + afi.elements.path.innerText.split('/').join('/</span><span class="nav">') + '</span></span>'
afi.elements.nav = parent.querySelector('#nav');
}
}
afi.directcall = {
exec_mkdir: function () {
const folder = prompt('新建目录名称', '')
if (folder && !afi.utils.isDupe(folder)) {
afi.rpc.mkdirCall(folder, afi.utils.refresh)
}
},
exec_touch: function () {
const file = prompt('新建文件名称', '')
if (file && !afi.utils.isDupe(file)) {
afi.rpc.touchCall(file, afi.utils.refresh)
}
},
exec_rm: function () {
const file = prompt('删除对象名称', '')
afi.rpc.rmCall(file, afi.utils.refresh)
},
exec_mv: function () {
const orig = prompt('源地址', '')
const dest = prompt('目标地址', '')
if (orig && !afi.utils.isDupe(dest)) {
afi.rpc.mvCall(afi.utils.prependPath(orig), afi.utils.prependPath(dest), afi.utils.refresh)
}
},
bread_click: function (e) {
const p = Array.from(document.getElementById('nav').childNodes).map(k => k.innerText)
const i = p.findIndex(s => s === e.target.innerText)
var dst = p.slice(0, i + 1).join('').slice(1)
if (!dst.startsWith('/')) {
dst = "/" + dst
}
const target = location.origin + afi.params.extraPath + afi.utils.encodeURIHash(dst)
afi.interface.browseTo(target, false)
}
}
function init() {
afi.elements = {
itemlinks: Array.from(document.querySelectorAll('a.item-links')),
table: document.getElementById("index-table"),
badge: document.getElementById("badge"),
path: document.getElementById("path"),
uploader: document.getElementById("clickupload"),
nav: document.getElementById("nav"),
title: document.head.querySelector("title"),
dropGrid: document.getElementById("drop-grid"),
barDisplay: document.getElementById("bar-display"),
barProc: document.getElementById("bar-proc"),
helpPanel: document.getElementById('help-panel'),
helpToggle: document.getElementById('help-toggle'),
}
afi.auth.checkAuth().then(() => {
afi.utils.updateButtons();
});
afi.interface.setBreadcrumbs()
afi.elements.uploader.addEventListener('change', () => {
const files = Array.from(afi.elements.uploader.files);
files.forEach(file => {
if (!afi.utils.isDupe(file.name)) {
afi.uploader.uploadFile(file, '/' + file.name);
}
});
}, false);
afi.elements.dropGrid.ondragend = afi.elements.dropGrid.ondragexit = afi.elements.dropGrid.ondragleave = e => {
afi.utils.cancelDefault(e)
afi.elements.dropGrid.style.display = 'none'
}
let draggingSrc
document.ondragenter = e => {
afi.utils.cancelDefault(e)
afi.utils.resetHighlight()
if (!draggingSrc) {
afi.elements.dropGrid.style.display = 'flex'
e.dataTransfer.dropEffect = 'copy'
} else if (draggingSrc) {
const t = getClosestRow(e.target)
afi.utils.isFolder(t.firstChild) && setHighlight(t)
}
}
afi.elements.itemlinks.forEach(link => {
const row = link.parentElement.parentElement.parentElement.querySelector('.item-icon');
if (!row) return;
let clickTimer = null;
let isDoubleClick = false;
row.addEventListener('click', (e) => {
if (link.innerText === '../') return;
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
clickTimer = setTimeout(() => {
if (!isDoubleClick) {
e.stopPropagation();
if (confirm('确认删除此项目?')) {
afi.rpc.rmCall(afi.utils.prependPath(link.getAttribute('href')), afi.utils.refresh);
}
}
isDoubleClick = false;
clickTimer = null;
}, 300);
});
row.addEventListener('dblclick', (e) => {
e.stopPropagation();
isDoubleClick = true;
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
if (link.innerText === '../') return;
const newName = prompt('新名称或目标地址', link.innerText);
if (newName && !afi.utils.isDupe(newName)) {
afi.rpc.mvCall(
afi.utils.prependPath(link.innerText),
afi.utils.prependPath(newName),
afi.utils.refresh
);
}
setTimeout(() => {
isDoubleClick = false;
}, 100);
});
});
document.ondragstart = e => { draggingSrc = e.target.innerHTML }
document.ondragend = e => resetHighlight()
document.ondragover = e => { afi.utils.cancelDefault(e); return false }
document.ondrop = e => {
afi.utils.cancelDefault(e)
afi.elements.dropGrid.style.display = 'none'
const t = afi.utils.getHighlight().firstChild
if (draggingSrc && t) {
const dest = t.innerHTML + draggingSrc
afi.consts.ensureDrop() || afi.rpc.mvCall(afi.utils.prependPath(draggingSrc), afi.utils.prependPath(dest), afi.utils.refresh)
} else if (e.dataTransfer.items.length) {
Array.from(e.dataTransfer.items).forEach(afi.interface.pushEntry.bind(afi.interface))
}
afi.utils.resetHighlight()
draggingSrc = null
return false
}
console.log('[AFI] Initialized')
}
window.onload = init

View File

@@ -1,387 +0,0 @@
@import url('https://fonts.loli.net/css2?family=Cascadia+Code:ital,wght@0,200..700;1,200..700&display=swap');
@import url("https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@400;700&display=swap");
:root {
--background: #ffffff;
--background-darker: #eeeeee;
--background-verydarker: #dddddd;
--foreground: #454545;
--accent: #0f59a4;
--accent-lighten: #2793ff;
/*本来有color-mix的, 可惜支持太新*/
--radius: 0;
--font-size: 1rem;
--line-height: 1.54em;
}
html {
/* 预留滚动条空间,但不会让背景色断开,保持内容稳定 */
scrollbar-gutter: stable;
}
@media (max-width: 640px) {
#index-table th:nth-child(5) {
display: none;
}
#index-table td:nth-child(6) {
display: none;
}
.ellipsis {
display: -webkit-box;
-webkit-box-orient: vertical;
line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.desktoponly {
display: none;
}
#index-table th:last-child,
#index-table td:last-child {
text-align: right;
padding: 0.25em 0 !important;
}
#index-table th:nth-child(2),
#index-table td:nth-child(3) {
width: 52%;
}
#index-table th:nth-child(3),
#index-table td:nth-child(4) {
display: none;
}
}
*,
*::before,
*::after {
background-color: var(--background);
color: var(--foreground);
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: "Cascadia Code", "Noto Sans SC", system-ui, sans-serif;
background: var(--background);
}
header {
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--background);
padding: 4px 32px 4px 20px;
border-bottom: 1px solid var(--background-verydarker);
z-index: 100;
}
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.logo-img {
display: inline-block;
height: calc(8px + 1em);
width: auto;
padding: 0 1em 0 0;
vertical-align: middle;
}
#nav {
flex: 1;
font-size: 14px;
color: var(--foreground);
}
#operations {
display: flex;
gap: 8px;
}
.operation {
padding: 4px 0px;
border-color: transparent;
color: var(--accent);
cursor: pointer;
font-size: 14px;
}
.ellipsis {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
#index-table {
margin: 20px 20px 0 20px;
width: auto;
border-collapse: collapse;
background: var(--background);
overflow: auto;
font-size: 12px;
}
#index-table th,
#index-table td {
/*hack td高度计算的怪异标准(明确指定), 不然icon的div没高度显示不了icon*/
height: 1px;
text-align: left;
/*Noto Fonts SC 和 Cascadia Code(以及各种utf-8 fallback字体) 的em不同, 所以用父元素统一基准, 不用子元素(可能没中文), 不然icon大小不齐*/
/*然后导致rem不能有可变的多语言内容, 不然页面之间照样变*/
/*css设计时就不能设置以指定字体为参照的em吗? 本来这个问题能用ie盒模型+"指定字体e"解决的 这下非要导致每行content间距可能不一样*/
padding: 0.25rem 0.5rem;
/*em单位简直毫不兼容且毫无预测性*/
/*连ex这种东西都设计出来了 就不能设计一个e(任意字符)吗? */
/*最终在icon用px+硬编码数字 懒得折腾了*/
/*真空球形鸡设计*/
}
#index-table thead th {
font-weight: 800;
text-transform: uppercase;
/*英文标题大写, 虽然我用中文*/
letter-spacing: 0.5px;
}
#index-table tbody th,
#index-table tbody td {
text-align: left;
border-bottom: 1px solid var(--background-darker);
}
#index-table tr:hover {
background: var(--background-darker);
}
#index-table th:nth-child(1) {
min-width: 2.5em;
padding: 0.25em 0;
}
#index-table td:nth-child(1) {
width: 1.5em;
padding: 0.25em 0;
}
#index-table td:nth-child(2) {
width: 0.5em;
padding: 0.25em 0em;
/*就一个'>'号*/
}
#index-table th:nth-child(2),
#index-table td:nth-child(3) {
max-width: 0;
/*允许压缩*/
min-width: 120px;
}
#index-table th:nth-child(3),
#index-table td:nth-child(4) {
width: auto
/*能占就占满*/
}
#index-table th:nth-child(4) {
text-align: center;
}
#index-table td:nth-child(5) {
text-align: right;
width: 3em;
}
#index-table th:nth-child(5) {
text-align: left;
}
#index-table td:nth-child(6) {
width: 12em;
}
#index-table th:last-child,
#index-table td:last-child {
text-align: right;
padding-right: 2em;
max-width: 0;
/*允许压缩*/
min-width: 4em;
}
.placeholder {
flex: 1;
}
footer {
position: sticky;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 12px 4px 12px;
background: var(--background);
border-top: 1px solid var(--background-verydarker);
font-size: 12px;
color: var(--foreground);
z-index: 100;
}
@keyframes fade {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.pulse {
animation: fade 5s 1;
}
.item-selected::before {
content: ">";
justify-content: center;
align-items: center;
font-size: 12px;
color: var(--accent);
font-weight: bold;
}
.highlight {
background-color: var(--accent-lighten);
}
#drop-grid {
align-items: center;
display: none;
justify-content: center;
position: fixed;
top: 0px;
bottom: 0px;
right: 0px;
left: 0px;
z-index: 999;
border: 5px dashed var(--accent);
margin: 0px;
border-radius: 5px;
text-align: center;
font-size: 4em;
color: var(--accent);
opacity: 1;
background-color: rgba(255, 255, 255, 0.6);
padding: 20px;
}
#drop-grid::after {
content: "释放光标以传输文件";
color: var(--foreground);
background-color: rgba(255, 255, 255, 0.6);
text-align: center;
}
#notification {
display: flex;
flex-direction: row;
padding: 0 0;
}
#notification * {
padding: 0 0;
}
#statbar {
display: flex;
flex-direction: row;
padding: 0 0;
}
#statbar * {
padding: 0 0;
}
.nav:hover {
color: var(--accent);
}
.item-icon {
height: 16px;
width: auto;
/*垃圾css*/
cursor: pointer;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.type-icon-folder {
background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNzc0NjkwMDA5MjU1IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEwNTMwIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgd2lkdGg9IjIwMCIgaGVpZ2h0PSIyMDAiPjxwYXRoIGQ9Ik04ODAgMjk4LjRINTIxTDQwMy43IDE4Ni4yYy0xLjUtMS40LTMuNS0yLjItNS41LTIuMkgxNDRjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjU5MmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg3MzZjMTcuNyAwIDMyLTE0LjMgMzItMzJWMzMwLjRjMC0xNy43LTE0LjMtMzItMzItMzJ6TTg0MCA3NjhIMTg0VjI1NmgxODguNWwxMTkuNiAxMTQuNEg4NDBWNzY4eiIgcC1pZD0iMTA1MzEiPjwvcGF0aD48L3N2Zz4=") !important;
}
.type-icon-file {
background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNzc0Njg5OTcyMTU2IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjUzNDAiIGRhdGEtc3BtLWFuY2hvci1pZD0iYTMxM3guc2VhcmNoX2luZGV4LjAuaTEuM2YwMTNhODFMUkJncXMiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+PHBhdGggZD0iTTg1NC42IDI4OC42TDYzOS40IDczLjRjLTYtNi0xNC4xLTkuNC0yMi42LTkuNEgxOTJjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjgzMmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg2NDBjMTcuNyAwIDMyLTE0LjMgMzItMzJWMzExLjNjMC04LjUtMy40LTE2LjctOS40LTIyLjd6TTc5MC4yIDMyNkg2MDJWMTM3LjhMNzkwLjIgMzI2eiBtMS44IDU2MkgyMzJWMTM2aDMwMnYyMTZjMCAyMy4yIDE4LjggNDIgNDIgNDJoMjE2djQ5NHoiIHAtaWQ9IjUzNDEiIGRhdGEtc3BtLWFuY2hvci1pZD0iYTMxM3guc2VhcmNoX2luZGV4LjAuaTIuM2YwMTNhODFMUkJncXMiPjwvcGF0aD48L3N2Zz4=");
}
#help-panel {
display: flex;
text-align: center;
width: 100%;
}
#help-panel h3 {
padding: 0.2em 0 0 0;
margin: 0;
}
#help-panel p {
padding: 0.3em 0 0.5em 0;
line-height: 1.5em;
}
#help-panel table {
border-collapse: collapse;
align-items: center;
width: 40em;
max-height: 10em;
min-width: 30em;
max-width: 50%;
margin: auto;
border: 4px solid var(--foreground);
}
#help-panel table td {
border: 2px solid var(--background-darker);
}
#help-panel table * {
padding: 0.3em 1em;
}

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>