Compare commits
2 Commits
41949ef7f4
...
1140ffc61f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1140ffc61f | |||
| 2112518f80 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,4 +16,6 @@ afi-dev-windows.exe
|
|||||||
afi-dev.test
|
afi-dev.test
|
||||||
|
|
||||||
*.out
|
*.out
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ afi-dev 开发版本释出二进制
|
|||||||
afi.go 主模块文件
|
afi.go 主模块文件
|
||||||
afi_embed.go 嵌入资源与模板配置
|
afi_embed.go 嵌入资源与模板配置
|
||||||
afi_embed_dev.go 热重载资源配置
|
afi_embed_dev.go 热重载资源配置
|
||||||
go.mod Golang 模块定义
|
go.mod Go/模块定义
|
||||||
|
benchdir 性能测试示例目录
|
||||||
LICENSE MIT 协议文件
|
LICENSE MIT 协议文件
|
||||||
README.md 自述文件
|
README.md 自述文件
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -18,7 +18,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
|
|||||||
- 基本的用户管理系统 (基于 HTTP Basic Authication), 我们移除了 Gossa 单一的只读/可写模式 (即 -ro 参数), 并保留了未登录状态下的默认只读模式
|
- 基本的用户管理系统 (基于 HTTP Basic Authication), 我们移除了 Gossa 单一的只读/可写模式 (即 -ro 参数), 并保留了未登录状态下的默认只读模式
|
||||||
- 支持跳过隐藏项目 (以 `.` 开头的文件/目录)
|
- 支持跳过隐藏项目 (以 `.` 开头的文件/目录)
|
||||||
- 开发/生产模式分离, 便于免编译重载前端资源
|
- 开发/生产模式分离, 便于免编译重载前端资源
|
||||||
- 同时提供简易的 RPC 调用接口便于编程与 webdav 接口(TODO)便于挂载, 网页用户界面的 JavaScript 使用前者
|
- 同时提供简易的 RPC 调用接口, 并加入了 WebDAV 接口便于编程与挂载, 网页用户界面的 JavaScript 使用前者
|
||||||
- 低占用高性能, 网页用户界面每秒可承受万级以上响应并持续保持毫秒级低延迟, 接口性能更高
|
- 低占用高性能, 网页用户界面每秒可承受万级以上响应并持续保持毫秒级低延迟, 接口性能更高
|
||||||
- 默认启用 gzip 压缩, 并为 iframe 嵌入优化
|
- 默认启用 gzip 压缩, 并为 iframe 嵌入优化
|
||||||
- 性能高效, 提供毫秒级响应, 用户使用体验不让位于技术审美与性能
|
- 性能高效, 提供毫秒级响应, 用户使用体验不让位于技术审美与性能
|
||||||
@@ -26,10 +26,9 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
|
|||||||
|
|
||||||
## 安全与不安全设计
|
## 安全与不安全设计
|
||||||
|
|
||||||
考虑到此程序可能被使用的场景与便于方案选型, 笔者必须指出此程序存在的问题, 以免您产生虚假的安全感
|
考虑到此程序可能被使用的场景与便于方案选型, 笔者在此指出此程序存在的问题, 以免产生虚假的安全感
|
||||||
如果您在为安全性抉择是否使用此项目, 我们高度建议您不要使用它
|
如果您在为安全性抉择是否使用此项目, 我们高度建议您不要使用它
|
||||||
如您发现了其他可能的漏洞, 请在 issues 指出, 若属实, 笔者会将其修复或加入下方列表, 并且将您的名字加入致谢名单
|
如您发现了其他可能的漏洞, 请在 issues 指出, 若属实, 笔者会将其修复或加入下方列表, 并且将您的名字加入致谢名单
|
||||||
注意: 请不要把"文档写出来了"等同于"只有这些问题"(我们当然追求安全审计文档的完善度)
|
|
||||||
|
|
||||||
安全建议:
|
安全建议:
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
|
|||||||
|
|
||||||
不安全设计:
|
不安全设计:
|
||||||
- 不能防范且无法容忍的攻击
|
- 不能防范且无法容忍的攻击
|
||||||
- 如上文所述, 这里没写不等于没有, 欢迎通过 issues 提出
|
- 没发现, 但这里没写不等于没有, 欢迎通过 issues 提出
|
||||||
- 未防范但几乎不可能成功的攻击
|
- 未防范但几乎不可能成功的攻击
|
||||||
- Timing Attack (未防范, 密码在后端明文比较, 但可通过设置较不寻常的用户名规避) -> 我们未来大概率不会改
|
- Timing Attack (未防范, 密码在后端明文比较, 但可通过设置较不寻常的用户名规避) -> 我们未来大概率不会改
|
||||||
- 能防范的攻击
|
- 能防范的攻击
|
||||||
@@ -63,7 +62,7 @@ AFI(Agile File Interface) 是 Go 编写的轻量级文件服务器, 是基于 Go
|
|||||||
- DoS 攻击 - 我们认为这件事应该由 nginx 反向代理预防
|
- DoS 攻击 - 我们认为这件事应该由 nginx 反向代理预防
|
||||||
- 请求大小和配额无限制导致的超大文件占用和可能导致的 OOM 崩溃 - 我们认为这件事应该由 nginx 反向代理或磁盘配额限制预防, 并且就事实而言, 内部威胁不在模型内, 这很大程度上是有权限的用户行为不当引起的问题
|
- 请求大小和配额无限制导致的超大文件占用和可能导致的 OOM 崩溃 - 我们认为这件事应该由 nginx 反向代理或磁盘配额限制预防, 并且就事实而言, 内部威胁不在模型内, 这很大程度上是有权限的用户行为不当引起的问题
|
||||||
- 无自带的响应超时: 可能招致 Slowloris 服务瘫痪 - 但这可由 nginx 反向代理预防
|
- 无自带的响应超时: 可能招致 Slowloris 服务瘫痪 - 但这可由 nginx 反向代理预防
|
||||||
- 权限提升风险: AFI 以什么用户运行, 就有什么权限 -- 如果以 root 运行且将分享目录设置为根目录, 则任何登录用户都能删除系统, 或者通过上传 `.ssh/authorized_keys` 或 `.bashrc` 远程控制您的服务器操作系统 -> 我们改不了
|
- 权限提升风险: AFI 以什么用户运行, 就有什么权限 - 如果以 root 运行且将分享目录设置为根目录, 则任何登录用户都能删除系统, 或者通过上传 `.ssh/authorized_keys` 或 `.bashrc` 远程控制您的服务器操作系统 -> 我们改不了
|
||||||
- 未默认杜绝的隐私问题:
|
- 未默认杜绝的隐私问题:
|
||||||
- 外部字体 Referer 泄露 (仅会得知您访问的链接, 但如果您需要, 可以移除前端中引用的在线字体链接) 就事实而言, 我们认为外部字体提供商不太可能有收集这种数据的动机, 但如果您在高敏感内网部署, 则您可能不希望任何外部服务能够得知内网的文件结构
|
- 外部字体 Referer 泄露 (仅会得知您访问的链接, 但如果您需要, 可以移除前端中引用的在线字体链接) 就事实而言, 我们认为外部字体提供商不太可能有收集这种数据的动机, 但如果您在高敏感内网部署, 则您可能不希望任何外部服务能够得知内网的文件结构
|
||||||
- 日志可能泄漏敏感信息 (可以通过修改源代码编译解决, 但我们提供的二进制释出没有移除敏感信息输出)
|
- 日志可能泄漏敏感信息 (可以通过修改源代码编译解决, 但我们提供的二进制释出没有移除敏感信息输出)
|
||||||
@@ -115,27 +114,28 @@ export GOBIN=$GOPATH/bin/
|
|||||||
```text
|
```text
|
||||||
Test Platform: AMD Ryzen 3600X, tmpfs, 12 item with DESCRIPT.ION in directory
|
Test Platform: AMD Ryzen 3600X, tmpfs, 12 item with DESCRIPT.ION in directory
|
||||||
OS: glibc linux-zen 6.19.10
|
OS: glibc linux-zen 6.19.10
|
||||||
Date: 2026-04-02
|
Date: 2026-04-02, 04-12
|
||||||
Version: latest
|
Version: latest
|
||||||
Command: wrk -t8 -c50 -d15s http://addr/
|
Command: wrk -t8 -c50 -d15s http://addr/
|
||||||
Test Address: /
|
Test Address: /
|
||||||
|
|
||||||
QPS Performace from test:
|
QPS Performace from test:
|
||||||
Server QPS Data QPS Bar (higher is better) Gzip Page
|
Server QPS Data QPS Bar (higher is better) Gzip Version Page
|
||||||
Nginx 60,242 QPS ████████████████████████████████ Disabled Default Index
|
Nginx 60,242 QPS ████████████████████████████████ Disabled 1.28.3 Default Index
|
||||||
Apache 34,207 QPS █████████████████░░░░░░░░░░░░░░░ Disabled Default Index
|
Apache 34,207 QPS █████████████████░░░░░░░░░░░░░░░ Disabled 2.4.66 Default Index
|
||||||
AFI 15,262 QPS ████████░░░░░░░░░░░░░░░░░░░░░░░░ BestSpeed Full-functional web file manager (without CSS & JS embedded in bench) (net/http)
|
AFI 21,914 QPS ███████████░░░░░░░░░░░░░░░░░░░░░ BestSpeed 26.4.26 Web Interface (without CSS & JS embedded in bench) (net/http)
|
||||||
PyPy 4,169 QPS ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled Default Index (http.server)
|
AFI(old) 15,262 QPS ████████░░░░░░░░░░░░░░░░░░░░░░░░ BestSpeed 26.4.3 Web Interface (without CSS & JS embedded in bench) (net/http)
|
||||||
CPython 2,128 QPS █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled Default Index (http.server)
|
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:
|
Latency from the same test:
|
||||||
Server Average Latency Bar (lower is better) Maximum
|
Server Average Latency Bar (lower is better) Maximum
|
||||||
Nginx 0.91ms █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 14.49ms
|
Nginx 0.91ms █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 14.49ms ▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
||||||
Apache 1.43ms ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 41.05ms
|
Apache 1.43ms ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 41.05ms ▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
||||||
AFI 4.69ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 67.67ms
|
AFI 26.4.26 3.01ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 36.24ms ▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
||||||
CPython 18.78ms ████████████████████░░░░░░░░░░░░ 1.67s
|
AFI 26.4.3 4.69ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 67.67ms ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
||||||
PyPy 23.86ms ██████████████████████████░░░░░░ 1.67s
|
CPython 18.78ms ████████████████████░░░░░░░░░░░░ 1.67s ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓...
|
||||||
|
PyPy 23.86ms ██████████████████████████░░░░░░ 1.67s ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓...
|
||||||
Socket Errors from the same test:
|
Socket Errors from the same test:
|
||||||
Server Number (lower is better)
|
Server Number (lower is better)
|
||||||
Nginx 0
|
Nginx 0
|
||||||
|
|||||||
429
afi.go
429
afi.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
@@ -28,10 +29,11 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ver = "26.04.03"
|
const ver = "26.4.26"
|
||||||
|
|
||||||
// rowTemplate 定义文件列表中的每一行数据结构
|
// rowTemplate 定义文件列表中的每一行数据结构
|
||||||
type rowTemplate struct {
|
type rowTemplate struct {
|
||||||
@@ -64,6 +66,7 @@ var skipHidden = flag.Bool("k", false, "跳过隐藏文件")
|
|||||||
// var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式
|
// var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式
|
||||||
var title = flag.String("title", "%PATH%", "页面标题, 用 %PATH% 指代完整路径, %ITEM% 指代末端文件/目录名, 不会泄露根目录目录名")
|
var title = flag.String("title", "%PATH%", "页面标题, 用 %PATH% 指代完整路径, %ITEM% 指代末端文件/目录名, 不会泄露根目录目录名")
|
||||||
var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据 (也可以用环境变量 AFI_AUTH 设置, AFI_AUTH 是最优先且最安全的)") // 这个flag只能用来调试 不然服务器一个ps就泄露了
|
var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据 (也可以用环境变量 AFI_AUTH 设置, AFI_AUTH 是最优先且最安全的)") // 这个flag只能用来调试 不然服务器一个ps就泄露了
|
||||||
|
var dav = flag.Bool("dav", false, "启用 WebDAV 接口")
|
||||||
|
|
||||||
// rpcCall 定义RPC调用的JSON数据结构
|
// rpcCall 定义RPC调用的JSON数据结构
|
||||||
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
|
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
|
||||||
@@ -73,9 +76,32 @@ type rpcCall struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rootPath = "" // 共享目录的根路径(文件系统中的绝对路径)
|
var rootPath = "" // 共享目录的根路径(文件系统中的绝对路径)
|
||||||
var handler http.Handler // HTTP处理器, 用于处理静态文件服务
|
|
||||||
var authTable = make(map[string]string)
|
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
|
// check 检查错误, 如果错误不为nil则panic
|
||||||
// 用于简化错误处理
|
// 用于简化错误处理
|
||||||
func check(e error) {
|
func check(e error) {
|
||||||
@@ -88,7 +114,7 @@ func check(e error) {
|
|||||||
// 用于捕获panic并返回500错误, 同时记录日志
|
// 用于捕获panic并返回500错误, 同时记录日志
|
||||||
// w: HTTP响应写入器
|
// w: HTTP响应写入器
|
||||||
// s: 日志信息参数
|
// s: 日志信息参数
|
||||||
func exitPath(w http.ResponseWriter, s ...interface{}) {
|
func exitPath(w http.ResponseWriter, s ...any) {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
// 发生panic时记录错误并返回500状态码
|
// 发生panic时记录错误并返回500状态码
|
||||||
log.Println("error", s, r)
|
log.Println("error", s, r)
|
||||||
@@ -103,78 +129,72 @@ func exitPath(w http.ResponseWriter, s ...interface{}) {
|
|||||||
// 例如: 1024 -> "1.0k", 1048576 -> "1.0M"
|
// 例如: 1024 -> "1.0k", 1048576 -> "1.0M"
|
||||||
// bytes: 文件大小(字节)
|
// bytes: 文件大小(字节)
|
||||||
// 返回: 格式化后的字符串(如"1.5M")
|
// 返回: 格式化后的字符串(如"1.5M")
|
||||||
|
var humanUnits = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
|
||||||
|
|
||||||
func humanize(bytes int64) string {
|
func humanize(bytes int64) string {
|
||||||
b := float64(bytes)
|
if bytes < 1024 {
|
||||||
u := 0
|
return strconv.FormatInt(bytes, 10) + "B"
|
||||||
for {
|
}
|
||||||
if b < 1024 {
|
f := float64(bytes) / 1024
|
||||||
// 根据当前数量级选择合适的单位
|
u := 1
|
||||||
return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}[u]
|
for f >= 1024 && u < len(humanUnits)-1 {
|
||||||
}
|
f /= 1024
|
||||||
b = b / 1024
|
|
||||||
u++
|
u++
|
||||||
}
|
}
|
||||||
|
return strconv.FormatFloat(f, 'f', 1, 64) + humanUnits[u]
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupDesc 返回指定文件在 DESCRIPT.ION 中的注释内容
|
// parseDescriptIon 只打开一次 DESCRIPT.ION 并返回 map,大幅提升目录列表性能
|
||||||
func lookupDesc(fullPath string) string {
|
func parseDescriptIon(dirPath string) map[string]string {
|
||||||
// 1. 获取绝对路径并提取目录和文件名
|
descMap := make(map[string]string)
|
||||||
absPath, err := filepath.Abs(fullPath)
|
descFilePath := filepath.Join(dirPath, "DESCRIPT.ION")
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
dir := filepath.Dir(absPath)
|
|
||||||
fileName := filepath.Base(absPath)
|
|
||||||
|
|
||||||
if fileName == "DESCRIPT.ION" {
|
// 默认描述, 可被覆写
|
||||||
return "备注元数据"
|
descMap["DESCRIPT.ION"] = "备注元数据"
|
||||||
}
|
descMap["README.md"] = "自述文件"
|
||||||
|
descMap["LICENSE"] = "许可证"
|
||||||
|
descMap[".gitignore"] = "Git/文件忽略模式"
|
||||||
|
descMap[".git"] = "Git/裸仓库目录"
|
||||||
|
|
||||||
// 2. 尝试打开该目录下的 DESCRIPT.ION (不区分大小写)
|
if file, err := os.Open(descFilePath); err == nil {
|
||||||
// 在 Windows 下直接打开即可,在 Linux 下可能需要遍历目录匹配文件名
|
defer file.Close()
|
||||||
descFilePath := filepath.Join(dir, "DESCRIPT.ION")
|
|
||||||
file, err := os.Open(descFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// 3. 逐行扫描文件内容
|
scanner := bufio.NewScanner(file)
|
||||||
scanner := bufio.NewScanner(file)
|
for scanner.Scan() {
|
||||||
for scanner.Scan() {
|
line := strings.TrimSpace(scanner.Text())
|
||||||
line := strings.TrimSpace(scanner.Text())
|
if line == "" {
|
||||||
if line == "" {
|
continue
|
||||||
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:])
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 普通格式:文件名 描述 (空格分隔)
|
var targetName, description string
|
||||||
parts := strings.SplitN(line, " ", 2)
|
|
||||||
targetName = parts[0]
|
// 处理带双引号的文件名格式,例如:"My Photo.jpg" This is a description
|
||||||
if len(parts) > 1 {
|
if strings.HasPrefix(line, `"`) {
|
||||||
description = strings.TrimSpace(parts[1])
|
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 生成目录列表页面并返回
|
// replyList 生成目录列表页面并返回
|
||||||
@@ -183,111 +203,174 @@ func lookupDesc(fullPath string) string {
|
|||||||
// r: HTTP请求
|
// r: HTTP请求
|
||||||
// fullPath: 目录的绝对路径
|
// fullPath: 目录的绝对路径
|
||||||
// path: 请求的URL路径
|
// path: 请求的URL路径
|
||||||
func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string) {
|
// dirFi: 目录的 FileInfo(由调用者提供,避免重复 stat)
|
||||||
// 读取目录内容
|
func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string, dirFi fs.FileInfo) {
|
||||||
files, err := os.ReadDir(fullPath)
|
dirMtime := dirFi.ModTime()
|
||||||
check(err)
|
// ETag 基于目录 mtime,目录内容变化时自动失效
|
||||||
|
etag := fmt.Sprintf(`W/"%x"`, dirMtime.UnixNano())
|
||||||
|
|
||||||
// 按文件名(不区分大小写)排序
|
// 缓存查找
|
||||||
sort.Slice(files, func(i, j int) bool {
|
dirCacheMu.RLock()
|
||||||
nameI := files[i].Name()
|
entry, ok := dirCache[fullPath]
|
||||||
nameJ := files[j].Name()
|
dirCacheMu.RUnlock()
|
||||||
|
|
||||||
// 1. 特殊逻辑:处理 DESCRIPT.ION
|
var folders, files []rowTemplate
|
||||||
// 如果 i 是目标文件,且 j 不是,则 i 应该在后 (返回 false)
|
|
||||||
if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") {
|
if ok && entry.mtime.Equal(dirMtime) {
|
||||||
return false
|
// 缓存命中 → ETag 检查
|
||||||
|
if r.Header.Get("If-None-Match") == entry.etag {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// 如果 j 是目标文件,且 i 不是,则 i 应该在前 (返回 true)
|
w.Header().Set("ETag", entry.etag)
|
||||||
if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") {
|
folders = entry.folders
|
||||||
return true
|
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, "/") {
|
if !strings.HasSuffix(path, "/") {
|
||||||
path += "/"
|
path += "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建当前路径
|
pathtext := "/" + strings.TrimPrefix(path, *extraPath)
|
||||||
pathtext := "/" + strings.TrimPrefix(path, *extraPath) // 删除extraPath前缀
|
|
||||||
p := pageTemplate{}
|
p := pageTemplate{}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
p.TimeStamp = now.Format(time.RFC3339)
|
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 {
|
if path != *extraPath {
|
||||||
p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"})
|
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.ExtraPath = template.HTML(html.EscapeString(*extraPath))
|
||||||
p.Path = template.HTML(html.EscapeString(pathtext)) // 安全化路径
|
p.Path = template.HTML(html.EscapeString(pathtext))
|
||||||
if 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 {
|
} else {
|
||||||
p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", filepath.Base(fullPath), -1)
|
p.Title = strings.ReplaceAll(strings.ReplaceAll(*title, "%PATH%", pathtext), "%ITEM%", filepath.Base(fullPath))
|
||||||
}
|
|
||||||
// 遍历目录中的每个条目
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查客户端是否支持gzip压缩
|
// 渲染到 buffer,按大小决定是否 gzip(<1KB 直接发,压缩了更慢)
|
||||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
var buf bytes.Buffer
|
||||||
w.Header().Set("Content-Type", "text/html")
|
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")
|
w.Header().Add("Content-Encoding", "gzip")
|
||||||
// 使用gzip压缩响应内容, 提高传输效率
|
gz := gzipWriterPool.Get().(*gzip.Writer)
|
||||||
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
|
gz.Reset(w)
|
||||||
check(err)
|
gz.Write(body)
|
||||||
defer gz.Close()
|
gz.Close()
|
||||||
tmpl.Execute(gz, p)
|
gzipWriterPool.Put(gz)
|
||||||
} else {
|
} 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响应写入器
|
// w: HTTP响应写入器
|
||||||
// r: HTTP请求
|
// r: HTTP请求
|
||||||
func doContent(w http.ResponseWriter, r *http.Request) {
|
func doContent(w http.ResponseWriter, r *http.Request) {
|
||||||
// query := r.URL.Query()
|
|
||||||
// 此处可以接收参数
|
|
||||||
// 如果URL不以配置的前缀开头, 重定向到正确的前缀
|
// 如果URL不以配置的前缀开头, 重定向到正确的前缀
|
||||||
if !strings.HasPrefix(r.URL.Path, *extraPath) {
|
if !strings.HasPrefix(r.URL.Path, *extraPath) {
|
||||||
http.Redirect(w, r, *extraPath, http.StatusFound)
|
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)
|
defer exitPath(w, "get content", path)
|
||||||
// 获取安全的绝对路径
|
// 获取安全的绝对路径
|
||||||
fullPath := enforcePath(path)
|
fullPath := enforcePath(path)
|
||||||
stat, errStat := os.Stat(fullPath)
|
|
||||||
check(errStat)
|
|
||||||
|
|
||||||
// 如果是目录, 显示文件列表;如果是文件, 交给静态文件服务器处理
|
// 只 Open + Stat 一次,避免 FileServer 内部二次 stat
|
||||||
if stat.IsDir() {
|
f, err := os.Open(fullPath)
|
||||||
replyList(w, r, fullPath, path)
|
check(err)
|
||||||
|
fi, err := f.Stat()
|
||||||
|
check(err)
|
||||||
|
|
||||||
|
if fi.IsDir() {
|
||||||
|
f.Close()
|
||||||
|
replyList(w, r, fullPath, path, fi)
|
||||||
} else {
|
} 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中
|
// 打开文件并复制到ZIP中
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
check(err)
|
check(err)
|
||||||
defer file.Close()
|
|
||||||
_, err = io.Copy(headerWriter, file)
|
_, err = io.Copy(headerWriter, file)
|
||||||
|
file.Close()
|
||||||
check(err)
|
check(err)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -523,20 +610,19 @@ func rpc(w http.ResponseWriter, r *http.Request) {
|
|||||||
// p: 请求的路径
|
// p: 请求的路径
|
||||||
// 返回: 安全的绝对路径
|
// 返回: 安全的绝对路径
|
||||||
func enforcePath(p string) string {
|
func enforcePath(p string) string {
|
||||||
// 构建完整路径并移除URL前缀
|
|
||||||
joined := filepath.Join(rootPath, strings.TrimPrefix(p, *extraPath))
|
joined := filepath.Join(rootPath, strings.TrimPrefix(p, *extraPath))
|
||||||
fp, err := filepath.Abs(joined)
|
fp := filepath.Clean(joined)
|
||||||
sl, _ := filepath.EvalSymlinks(fp) // 评估符号链接的实际路径
|
|
||||||
|
|
||||||
// 安全检查:
|
if !strings.HasPrefix(fp, rootPath) || (*skipHidden && strings.Contains(p, "/.")) {
|
||||||
// 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) {
|
|
||||||
panic(errors.New("invalid path"))
|
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
|
return fp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,24 +647,31 @@ func main() {
|
|||||||
check(err)
|
check(err)
|
||||||
|
|
||||||
// 创建HTTP服务器
|
// 创建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+"rpc", rpc) // RPC接口 (增删移与校验和)
|
||||||
http.HandleFunc(*extraPath+"post", upload) // 文件上传接口
|
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+"auth", authState) // 仅检查登录接口(不会弹窗)
|
||||||
http.HandleFunc(*extraPath+"login", authLogin) // 登录接口
|
http.HandleFunc(*extraPath+"login", authLogin) // 登录接口
|
||||||
http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口
|
http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口
|
||||||
http.HandleFunc("/", doContent) // 主内容处理接口
|
http.HandleFunc("/", doContent) // 主内容处理接口
|
||||||
// 创建静态文件服务器, 用于直接提供文件下载
|
|
||||||
handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath)))
|
|
||||||
|
|
||||||
// 输出启动信息
|
// 输出启动信息
|
||||||
fmt.Printf("Agile File Interface 版本 %s\n", ver)
|
fmt.Printf("Agile File Interface 版本 %s\n", ver)
|
||||||
fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n")
|
fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n")
|
||||||
fmt.Printf("AFI 已启动, 根目录为 %s\n", rootPath)
|
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("身份验证信息: %s\n", *authstr)
|
||||||
fmt.Printf("正在监听: http://%s:%s%s\n", *host, *port, *extraPath)
|
fmt.Printf("正在监听: http://%s:%s%s\n", *host, *port, *extraPath)
|
||||||
|
|
||||||
|
|||||||
751
afi_webdav.go
Normal file
751
afi_webdav.go
Normal 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
0
benchdir/00
Normal file
0
benchdir/01
Normal file
0
benchdir/01
Normal file
0
benchdir/02
Normal file
0
benchdir/02
Normal file
0
benchdir/03
Normal file
0
benchdir/03
Normal file
0
benchdir/04
Normal file
0
benchdir/04
Normal file
0
benchdir/05
Normal file
0
benchdir/05
Normal file
0
benchdir/06
Normal file
0
benchdir/06
Normal file
0
benchdir/07
Normal file
0
benchdir/07
Normal file
0
benchdir/08
Normal file
0
benchdir/08
Normal file
0
benchdir/09
Normal file
0
benchdir/09
Normal file
0
benchdir/10
Normal file
0
benchdir/10
Normal file
0
benchdir/11
Normal file
0
benchdir/11
Normal file
0
benchdir/12
Normal file
0
benchdir/12
Normal file
0
benchdir/13
Normal file
0
benchdir/13
Normal file
0
benchdir/14
Normal file
0
benchdir/14
Normal file
0
benchdir/15
Normal file
0
benchdir/15
Normal file
0
benchdir/16
Normal file
0
benchdir/16
Normal file
0
benchdir/17
Normal file
0
benchdir/17
Normal file
0
benchdir/18
Normal file
0
benchdir/18
Normal file
0
benchdir/19
Normal file
0
benchdir/19
Normal file
0
benchdir/20
Normal file
0
benchdir/20
Normal file
0
benchdir/21
Normal file
0
benchdir/21
Normal file
0
benchdir/22
Normal file
0
benchdir/22
Normal file
0
benchdir/23
Normal file
0
benchdir/23
Normal file
0
benchdir/24
Normal file
0
benchdir/24
Normal file
0
benchdir/25
Normal file
0
benchdir/25
Normal file
0
benchdir/26
Normal file
0
benchdir/26
Normal file
0
benchdir/27
Normal file
0
benchdir/27
Normal file
0
benchdir/28
Normal file
0
benchdir/28
Normal file
0
benchdir/29
Normal file
0
benchdir/29
Normal file
0
benchdir/30
Normal file
0
benchdir/30
Normal file
31
benchdir/DESCRIPT.ION
Normal file
31
benchdir/DESCRIPT.ION
Normal 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
2
go.mod
@@ -1,3 +1,5 @@
|
|||||||
module afi
|
module afi
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
|
// Version 26.4.12 — see afi.go
|
||||||
|
|||||||
BIN
screenshot/helppanel.png
Normal file
BIN
screenshot/helppanel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
BIN
screenshot/logged-in.png
Normal file
BIN
screenshot/logged-in.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
BIN
screenshot/login.png
Normal file
BIN
screenshot/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
screenshot/readonly.png
Normal file
BIN
screenshot/readonly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
478
ui/script.js
478
ui/script.js
@@ -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
|
|
||||||
387
ui/style.css
387
ui/style.css
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<a style="display: none;" onclick="afi.directcall.exec_mv()" class="operation">移动对象</a>
|
<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.directcall.exec_rm()" class="operation">递归删除</a>
|
||||||
<a style="display: none;" onclick="afi.auth.logout()" 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>
|
<a style="user-select: none;" onclick="afi.utils.helpOn()" class="operation" id="help-toggle">帮助菜单</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -199,4 +199,4 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user