权限与功能改进

This commit is contained in:
2026-04-04 09:45:27 +08:00
parent 4a55a4bc36
commit 3206519910
4 changed files with 276 additions and 116 deletions

124
README.md
View File

@@ -2,23 +2,66 @@
本项目 fork 自 [Gossa, 由 Pierre Dubouilh 及 Gossa Authors 开发维护](https://github.com/pldubouilh/gossa), 并重新初始化 git. 本项目 fork 自 [Gossa, 由 Pierre Dubouilh 及 Gossa Authors 开发维护](https://github.com/pldubouilh/gossa), 并重新初始化 git.
一个轻量文件网页服务器, 无依赖且代码量极小, 易于审查. AFI 是 Go 编写的轻量文件服务器, 是基于 Gossa 的现代化增强维护分支
可与配置好的 rclone 与 fuse 实例搭配, 但不附带配置界面. 特色:
默认提供简洁现代的用户界面, 并包含以下功能: - 后端代码量低, 易于审查; 我们(虽然目前只有一个维护者 但是还是称"我们" :))在 Gossa 的基础上进行了大量重构, 大幅增强了前后端的可维护性并提升了效率
- 清晰现代且轻量的网页界面, 比原版 Gossa 的信息量更高且更悦目, 相较于 FileBrowser, AFI 易于嵌入至 `<iframe>` 且不依赖任何 web 框架, 操作无动画不拖泥带水, 无外部资源引用 (显然我们的前端不会引用 is-odd 和 is-even, 也不会因 left-pad 而崩溃 :D)
- 单个可执行文件, 无需数据库, 速度极快, 方便快速部署
- 与 FUSE 文件系统兼容 (如 rclone 实例配置的网络挂载)
- 支持拖拽上传文件和目录, 并以归档(.zip)形式方便地下载任意目录
- 支持 MD5, SHA-1, SHA-256, SHA-512 文件校验和的远程解算
- 支持通过 `DESCRIPT.ION` 文件存储的备注元信息
- 完备的键盘操作支持
- 基本的用户管理系统 (基于 HTTP Basic Authication), 我们移除了 Gossa 单一的只读/可写模式 (即 -ro 参数), 并保留了未登录的只读模式
- 支持跳过隐藏项目 (以 `.` 开头的文件/目录)
- 开发/生产模式分离, 便于免编译重载前端资源
- 同时提供 RPC 调用接口便于编程与 webdav 接口便于挂载, 网页用户界面的 JavaScript 使用前者
- 低占用高性能, 网页用户界面每秒可承受万级以上响应并持续保持毫秒级低延迟, 接口性能更高
- 默认启用 gzip 压缩, 并为 iframe 嵌入优化
- 性能高效, 提供真正的毫秒级响应, 用户使用体验不让位于技术审美与性能
* 文件/目录浏览与处理 ## 安全与不安全设计
* 拖放上传
* 轻量级网页用户界面, 毫秒级响应 考虑到此程序可能被使用的场景与便于方案选型, 笔者必须指出此程序存在的问题
* 键盘导航 如您发现了其他可能的漏洞, 请在 issues 指出, 若属实, 笔者会将其修复或加入下方列表
* 轻量级, 且易于维护的代码库
* 快速的 Go 语言静态服务器 安全建议:
* 基于 DESCRIPT.ION 的文件注释支持
* 可选的只读模式 - 强烈建议配置一个 nginx 反向代理
* 可选的 symlink 追踪 - 必要时设置超时, 限制请求大小与频率
* 支持 PWA - 完全避免使用 HTTP
* 多平台支持 - 必要时设置磁盘配额
- 安全设计:
- 可防止路径穿越攻击
- 有基本的权限管理系统 (基于 HTTP Basic Authication)
- 不安全设计:
- 不能完全防范的攻击
- XSS (不可防范)
- Timing Attack (不可防范, 但可通过设置较复杂的用户名规避)
- 能防范的攻击
- symlink 逃逸攻击 (可选且默认开启, 但开启后您就不能使用 linux 的符号链接便利了)
- 路径穿越攻击
- CSRF (HTTP Basic Authication 天然防范)
- 其他不安全设计:
- 密码以明文形式传输 (因此千万不要在非 HTTPS 环境公网部署或预留 HTTP 访问, 除非您完全不配置用户(相当于原版 Gossa 的只读模式))
- 后端密码参数化 (可登录服务器后用 ps 或类似工具查看)
- 日志可能泄漏敏感信息 (可以通过修改源代码编译解决, 但我们提供的二进制释出没有移除敏感信息输出)
- DoS 攻击 - 我们认为这件事应该由 nginx 反向代理预防
- 请求大小无限制导致的超大文件占用和可能导致的 OOM 崩溃 - 我们认为这件事应该由 nginx 反向代理或磁盘配额限制预防, 并且就事实而言, 这很大程度上是有权限的用户行为不当引起的问题
- 外部字体 Referer 泄露 (仅会得知您访问的链接, 但如果您需要, 可以移除前端中引用的在线字体链接) 就事实而言, 外部字体提供商没有收集这种数据的动机, 但如果您在内网部署, 则您可能不希望任何外部服务知道内网的文件结构
- 无自带的响应超时: 可能招致 Slowloris 服务瘫痪 - 但这可由 nginx 反向代理预防
- 权限提升风险: AFI 以什么用户运行, 就有什么权限 -- 如果以 root 运行且将分享目录设置为根目录, 则任何登录用户都能删除系统
- 未对特定安全需求进行的设计:
- 不支持细粒度权限控制 (我们支持配置多个用户, 但登录后您就只能是对分享目录下的文件完全可写(取决于运行 afi 的用户)的状态了)
- 没有自带的防暴力破解机制
- 没有操作审计日志
- 没有密码哈希存储 (后端明文比较)
- 可能被误解为是不恰当的设计
- 代码中不恰当的注释, 但这些注释(html, js 和 css 中的)事实上会被 go 的模板系统清除(即使有 "dev" tag), 因此您不必担心这些注释会随页面传递给浏览器, 在"查看源代码页"呈现给用户
- 注意: 这些不安全设计并非都是运维或服务器管理员能解决的, 因此请量需求而行
## 使用方法 ## 使用方法
@@ -33,3 +76,56 @@
go build -o afi # 单文件, 自包括 web 资源, 便于部署 go build -o afi # 单文件, 自包括 web 资源, 便于部署
go build -tags dev -o afi-dev # 使用运行目录(优先)或程序目录下的 web 资源 (ui 目录) go build -tags dev -o afi-dev # 使用运行目录(优先)或程序目录下的 web 资源 (ui 目录)
``` ```
> 如果您此前没有接触过 go, 并且讨厌 go 会在您的用户主目录创建一个 "go" 文件夹, 您可以设置以下环境变量把 go 的存储位置改成隐藏的 ".go" (仅 linux)
```text
export GOPATH=$HOME/.go
export GOMODCACHE=$GOPATH/pkg/mod/
export GOBIN=$GOPATH/bin/
```
## 实际性能数据
笔者此前一直在改进用户界面和增加实现调用接口
直到测试时, 并未对核心模板与重复过程调用做优化, 但它的性能目前已经处于准优秀水平
目前已经发现了众多可改进之处, 性能有大幅增长空间, 敬请期待
关于测试数据:
- AFI 本身"自缚一手": 坦率地说, 尽管在同一文件夹进行索引, 但仅 AFI 具有完备的文件管理器功能, 为显示元数据, 调用比其他服务器多, 页面体积和结构都比其他软件复杂, 并且只有 AFI 启用了 gzip(虽然是BestSpeed), 因此相较而言 AFI 在对比中比较吃亏且测试数据是不严谨的, 不能证明 "AFI 的性能是 Nginx 的 25%", 笔者提供这个数据仅仅是为了做除了 QPS 以外与其他服务器软件响应的性能参考(毕竟 QPS 和 CPU 性能挂钩, 树莓派上绝对跑不到下方的 QPS)
- 测试的地址是 web 界面, 不是数据接口, 理论上数据接口是单一调用, 更快但不能进行对比实验
- 您可用下方提供的命令在您的平台上获取测试数据 (请不要用它压力测试他人的网站!)
```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
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 embeded in bench) (net/http)
PyPy 4,169 QPS ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled Default Index (http.server)
CPython 2,128 QPS █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled 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
Socket Errors from the same test:
Server Number (lower is better)
Nginx 0
Apache 0
AFI 0
CPython 1
PyPy 11
```

95
afi.go
View File

@@ -32,7 +32,6 @@ import (
) )
// rowTemplate 定义文件列表中的每一行数据结构 // rowTemplate 定义文件列表中的每一行数据结构
// 用于在HTML模板中渲染项目的显示信息
type rowTemplate struct { type rowTemplate struct {
Name string // 文件/文件夹名称 Name string // 文件/文件夹名称
Href template.URL // 链接地址 Href template.URL // 链接地址
@@ -43,7 +42,6 @@ type rowTemplate struct {
} }
// pageTemplate 定义整个页面渲染所需的数据结构 // pageTemplate 定义整个页面渲染所需的数据结构
// 包含页面标题、路径前缀、读写模式标志以及文件和文件夹列表
type pageTemplate struct { type pageTemplate struct {
Title string // 页面标题 Title string // 页面标题
Path template.HTML // 页面路径 Path template.HTML // 页面路径
@@ -61,8 +59,9 @@ var extraPath = flag.String("prefix", "/", "afi 的 URL 访问路径前缀, 例
var symlinks = flag.Bool("symlinks", false, "跟随符号链接 \033[4m警告\033[0m: 符号链接可能会跳出已定义的\"根目录\" (默认值: false)") // 是否跟随符号链接 var symlinks = flag.Bool("symlinks", false, "跟随符号链接 \033[4m警告\033[0m: 符号链接可能会跳出已定义的\"根目录\" (默认值: false)") // 是否跟随符号链接
var verb = flag.Bool("verb", false, "详细输出") // 是否输出详细日志 var verb = flag.Bool("verb", false, "详细输出") // 是否输出详细日志
var skipHidden = flag.Bool("k", true, "\n跳过隐藏文件") // 是否跳过隐藏文件(以.开头) var skipHidden = flag.Bool("k", true, "\n跳过隐藏文件") // 是否跳过隐藏文件(以.开头)
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"}`, "可写用户的认证数据") // 如果非要安全 应该用stdin注入密码防盗窃, 这个flag只能用来调试 不然服务器一个ps就泄露了
// rpcCall 定义RPC调用的JSON数据结构 // rpcCall 定义RPC调用的JSON数据结构
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和) // 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
@@ -71,8 +70,9 @@ type rpcCall struct {
Args []string `json:"args"` // 方法参数 Args []string `json:"args"` // 方法参数
} }
var rootPath = "" // 共享目录的根路径绝对路径 var rootPath = "" // 共享目录的根路径(文件系统中的绝对路径)
var handler http.Handler // HTTP处理器, 用于处理静态文件服务 var handler http.Handler // HTTP处理器, 用于处理静态文件服务
var authTable = make(map[string]string)
// check 检查错误, 如果错误不为nil则panic // check 检查错误, 如果错误不为nil则panic
// 用于简化错误处理 // 用于简化错误处理
@@ -91,7 +91,6 @@ func exitPath(w http.ResponseWriter, s ...interface{}) {
// 发生panic时记录错误并返回500状态码 // 发生panic时记录错误并返回500状态码
log.Println("error", s, r) log.Println("error", s, r)
w.WriteHeader(500) w.WriteHeader(500)
w.Write([]byte("error"))
} else if *verb { } else if *verb {
// 详细模式下记录正常日志 // 详细模式下记录正常日志
log.Println(s...) log.Println(s...)
@@ -220,7 +219,6 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"}) p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"})
} }
p.ExtraPath = template.HTML(html.EscapeString(*extraPath)) p.ExtraPath = template.HTML(html.EscapeString(*extraPath))
p.Ro = *ro
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.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", "/", -1)
@@ -296,9 +294,8 @@ 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() // query := r.URL.Query()
name := query.Get("header") // 此处可以接收参数
name := query.Get("footer")
// 如果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)
@@ -321,11 +318,63 @@ func doContent(w http.ResponseWriter, r *http.Request) {
} }
} }
func checkAuth(username string, password string) bool {
return authTable[username] == password
// WARN: 这么搞不能防御 timing attack
// 切勿用于生产环境
}
func checkAuthRequest(w http.ResponseWriter, r *http.Request) bool {
username, password, ok := r.BasicAuth()
if !ok {
return false
}
if checkAuth(username, password) {
return true
} else {
return false
}
}
// 拒绝现代JWT, 古法auth, 我们有先进的https
// 这样不能防 XSS
// 但是至于XSS, 用户都在浏览器随便运行tampermonkey了我还能说什么呢, 说不定是用户想写脚本调试呢, 我把xss预防了让他们费劲去找token反而不好
// 这才叫利好curl, 而且还防 CSRF
func authLogin(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="AFI File Manager"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if checkAuth(username, password) {
http.Redirect(w, r, *extraPath, http.StatusFound)
} else {
w.Header().Set("WWW-Authenticate", `Basic realm="AFI File Manager"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
}
func authState(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if checkAuth(username, password) {
w.WriteHeader(http.StatusOK)
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
}
// upload 处理文件上传请求 // upload 处理文件上传请求
// 从multipart请求中读取文件内容并保存到指定路径 // 从multipart请求中读取文件内容并保存到指定路径
// w: HTTP响应写入器 // w: HTTP响应写入器
// r: HTTP请求 // r: HTTP请求
func upload(w http.ResponseWriter, r *http.Request) { func upload(w http.ResponseWriter, r *http.Request) {
if !checkAuthRequest(w, r) {
return
}
// 从header中获取目标路径 // 从header中获取目标路径
path := r.Header.Get("afi-path") path := r.Header.Get("afi-path")
defer exitPath(w, "upload", path) defer exitPath(w, "upload", path)
@@ -352,6 +401,9 @@ func upload(w http.ResponseWriter, r *http.Request) {
// w: HTTP响应写入器 // w: HTTP响应写入器
// r: HTTP请求 // r: HTTP请求
func zipRPC(w http.ResponseWriter, r *http.Request) { func zipRPC(w http.ResponseWriter, r *http.Request) {
if !checkAuthRequest(w, r) {
return
}
// 获取要打包的路径和ZIP文件名 // 获取要打包的路径和ZIP文件名
zipPath := r.URL.Query().Get("zipPath") zipPath := r.URL.Query().Get("zipPath")
zipName := r.URL.Query().Get("zipName") zipName := r.URL.Query().Get("zipName")
@@ -389,7 +441,7 @@ func zipRPC(w http.ResponseWriter, r *http.Request) {
header, err := zip.FileInfoHeader(f) header, err := zip.FileInfoHeader(f)
check(err) check(err)
header.Name = filepath.ToSlash(rel) // 统一使用斜杠分隔 header.Name = filepath.ToSlash(rel) // 统一使用斜杠分隔
header.Method = zip.Store // 使用存储模式不压缩 header.Method = zip.Store // 使用存储模式 (不压缩)
headerWriter, err := zipWriter.CreateHeader(header) headerWriter, err := zipWriter.CreateHeader(header)
check(err) check(err)
// 打开文件并复制到ZIP中 // 打开文件并复制到ZIP中
@@ -409,6 +461,9 @@ func zipRPC(w http.ResponseWriter, r *http.Request) {
// w: HTTP响应写入器 // w: HTTP响应写入器
// r: HTTP请求 // r: HTTP请求
func rpc(w http.ResponseWriter, r *http.Request) { func rpc(w http.ResponseWriter, r *http.Request) {
if !checkAuthRequest(w, r) {
return
}
var err error var err error
var rpc rpcCall var rpc rpcCall
defer exitPath(w, "rpc", &rpc) defer exitPath(w, "rpc", &rpc)
@@ -488,33 +543,37 @@ func main() {
if flag.Parse(); len(flag.Args()) == 1 { if flag.Parse(); len(flag.Args()) == 1 {
rootPath = flag.Args()[0] rootPath = flag.Args()[0]
} else { } else {
fmt.Printf("\nusage: ./afi [OPTIONS] ~/directory-to-share\n\n") fmt.Printf("Usage: ./afi [OPTION]... [ROOTDIR]\n\n")
flag.PrintDefaults() flag.PrintDefaults()
os.Exit(1) os.Exit(1)
} }
err := json.Unmarshal([]byte(*authstr), &authTable)
check(err)
// 获取共享目录的绝对路径 // 获取共享目录的绝对路径
var err error
rootPath, err = filepath.Abs(rootPath) rootPath, err = filepath.Abs(rootPath)
check(err) check(err)
// 创建HTTP服务器 // 创建HTTP服务器
server := &http.Server{Addr: *host + ":" + *port, Handler: handler} server := &http.Server{Addr: *host + ":" + *port, Handler: handler}
// 注册路由处理器(非只读模式下启用修改操作) // 注册路由处理器
if !*ro { 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 接口
http.HandleFunc(*extraPath+"auth", authState) // 仅检查登录接口(不会弹窗)
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))) handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath)))
// 输出启动信息 // 输出启动信息
fmt.Printf("Agile File Indexer 是 Gossa 的分支\n") fmt.Printf("Agile File Indexer\n")
fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n")
fmt.Printf("AFI 已启动, 根目录为 %s\n", rootPath) fmt.Printf("AFI 已启动, 根目录为 %s\n", rootPath)
fmt.Printf("详细输出: %t, 符号链接跟随: %t, 只读模式: %t, 跳过隐藏文件: %t\n", *verb, *symlinks, *ro, *skipHidden) fmt.Printf("详细输出: %t, 符号链接跟随: %t, 跳过隐藏文件: %t\n", *verb, *symlinks, *skipHidden)
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)
// 启动HTTP服务 // 启动HTTP服务

View File

@@ -1,4 +1,5 @@
afi = { afi = {
auth: {},
consts: {}, consts: {},
params: {}, params: {},
elements: {}, elements: {},
@@ -9,13 +10,39 @@ afi = {
directcall: {}, 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 = { afi.consts = {
leftPageWarn: "确认放弃正在进行的传输?", leftPageWarn: "确认放弃正在进行的传输?",
ensureDrop: () => !confirm('确认移动此对象?') ensureDrop: () => !confirm('确认移动此对象?')
} }
afi.params = { afi.params = {
readonly: readonly,
extraPath: extraPath extraPath: extraPath
} }
@@ -104,7 +131,6 @@ afi.utils = {
try { try {
this.getHighlight().classList.remove('highlight') this.getHighlight().classList.remove('highlight')
} catch (e) { } catch (e) {
/* */
} }
}, },
isHelpMode: () => afi.elements.helpPanel.style.display === 'block', isHelpMode: () => afi.elements.helpPanel.style.display === 'block',
@@ -120,18 +146,37 @@ afi.utils = {
afi.elements.table.style.display = 'table' afi.elements.table.style.display = 'table'
afi.elements.helpToggle.innerText = "帮助菜单" afi.elements.helpToggle.innerText = "帮助菜单"
afi.elements.helpToggle.onclick = afi.utils.helpOn 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 = { afi.rpc = {
rpc: function (call, args, cb) { rpc: function (call, args, cb) {
console.log('[AFI] RPC', call, args) console.log('[AFI] RPC', call, args);
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest();
xhr.open('POST', location.origin + afi.params.extraPath + '/rpc') xhr.open('POST', location.origin + afi.params.extraPath + '/rpc');
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8') xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xhr.send(JSON.stringify({ call, args })) xhr.withCredentials = true;
xhr.onload = cb xhr.send(JSON.stringify({ call, args }));
xhr.onerror = () => afi.pulsar.pulseFailure() xhr.onload = cb;
xhr.onerror = () => afi.pulsar.pulseFailure();
}, },
mkdirCall: function (path, cb) { mkdirCall: function (path, cb) {
@@ -173,36 +218,33 @@ afi.uploader = {
this.totalUploadedSize = [] this.totalUploadedSize = []
setTimeout(afi.utils.refresh, 200) setTimeout(afi.utils.refresh, 200)
afi.elements.barDisplay.style.display = 'none' afi.elements.barDisplay.style.display = 'none'
//afi.elements.barProc.innerText = "传输: 就绪"
} }
}, },
updatePercent: function (event) { updatePercent: function (event) {
this.totalUploadedSize[event.target.id] = event.loaded this.totalUploadedSize[event.target.id] = event.loaded
const ttlDone = this.totalUploadedSize.reduce((s, x) => s + x) const ttlDone = this.totalUploadedSize.reduce((s, x) => s + x)
const percent = Math.min(Math.floor(100 * ttlDone / this.totalUploadsSize), 100) + '%' // 此处消除对小文件元数据上传带来的误差, 理论上是不准的 const percent = Math.min(Math.floor(100 * ttlDone / this.totalUploadsSize), 100) + '%'
afi.elements.barProc.innerText = "传输: " + percent afi.elements.barProc.innerText = "传输: " + percent
}, },
upload: function (id, what, path, cbDone, cbErr, cbUpdate) { upload: function (id, what, path, cbDone, cbErr, cbUpdate) {
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest();
xhr.open('POST', location.origin + afi.params.extraPath + '/post') xhr.open('POST', location.origin + afi.params.extraPath + '/post');
xhr.setRequestHeader('afi-path', path) xhr.setRequestHeader('afi-path', path);
xhr.upload.addEventListener('load', cbDone) xhr.withCredentials = true;
xhr.upload.addEventListener('progress', cbUpdate) xhr.upload.addEventListener('load', cbDone);
xhr.upload.addEventListener('error', cbErr) xhr.upload.addEventListener('progress', cbUpdate);
xhr.upload.id = id xhr.upload.addEventListener('error', cbErr);
xhr.send(what) xhr.upload.id = id;
xhr.send(what);
}, },
uploadFile: function (file, path) { uploadFile: function (file, path) {
if (afi.params.readonly) return
path = decodeURI(location.pathname).slice(0, -1) + path path = decodeURI(location.pathname).slice(0, -1) + path
window.onbeforeunload = afi.consts.leftPageWarn window.onbeforeunload = afi.consts.leftPageWarn
afi.elements.barProc.style.display = afi.elements.barDisplay.style.display = 'block' afi.elements.barProc.style.display = afi.elements.barDisplay.style.display = 'block'
this.totalUploads += 1 this.totalUploads += 1
this.totalUploadsSize += file.size this.totalUploadsSize += file.size
//this.totalUploadedSize[this.totalUploads] = file.size
if (typeof upBarName !== 'undefined') { if (typeof upBarName !== 'undefined') {
upBarName.innerText = this.totalUploads > 1 ? this.totalUploads + ' 个文件' : file.name upBarName.innerText = this.totalUploads > 1 ? this.totalUploads + ' 个文件' : file.name
} }
@@ -310,38 +352,6 @@ afi.directcall = {
} }
} }
afi.checksum = {
getSum(type) {
upBarPc.style.display = 'block'
upBarPc.innerText = '远程求解校验和...'
upBarPc.style.width = '100%'
sumsOff()
sumCall(getASelected().innerText, type, loaded => {
navigator.clipboard.writeText(loaded.target.responseText) // 复制到剪贴板
upBarPc.style.display = 'none'
flicker(okBadge)
})
},
isSumsMode: () => sums.style.display === 'block',
sumsToggle: () => isSumsMode() ? sumsOff() : sumsOn(),
sumsOn() {
if (isFolder(getASelected())) {
alert('无法对目录求解校验和')
return
}
sums.style.display = 'block'
table.style.display = 'none'
},
sumsOff() {
if (!isSumsMode()) return
sums.style.display = 'none'
table.style.display = 'table'
return true
}
}
function init() { function init() {
afi.elements = { afi.elements = {
itemlinks: Array.from(document.querySelectorAll('a.item-links')), itemlinks: Array.from(document.querySelectorAll('a.item-links')),
@@ -357,6 +367,11 @@ function init() {
helpPanel: document.getElementById('help-panel'), helpPanel: document.getElementById('help-panel'),
helpToggle: document.getElementById('help-toggle'), helpToggle: document.getElementById('help-toggle'),
} }
afi.auth.checkAuth().then(() => {
afi.utils.updateButtons();
});
afi.interface.setBreadcrumbs() afi.interface.setBreadcrumbs()
afi.elements.uploader.addEventListener('change', () => { afi.elements.uploader.addEventListener('change', () => {
const files = Array.from(afi.elements.uploader.files); const files = Array.from(afi.elements.uploader.files);
@@ -373,7 +388,6 @@ function init() {
} }
let draggingSrc let draggingSrc
document.ondragenter = e => { document.ondragenter = e => {
if (afi.params.readonly) { return }
afi.utils.cancelDefault(e) afi.utils.cancelDefault(e)
afi.utils.resetHighlight() afi.utils.resetHighlight()
if (!draggingSrc) { if (!draggingSrc) {
@@ -393,7 +407,7 @@ function init() {
let isDoubleClick = false; let isDoubleClick = false;
row.addEventListener('click', (e) => { row.addEventListener('click', (e) => {
if (afi.params.readonly || link.innerText === '../') return; if (link.innerText === '../') return;
if (clickTimer) { if (clickTimer) {
clearTimeout(clickTimer); clearTimeout(clickTimer);
clickTimer = null; clickTimer = null;
@@ -403,7 +417,7 @@ function init() {
if (!isDoubleClick) { if (!isDoubleClick) {
e.stopPropagation(); e.stopPropagation();
if (confirm('确认删除此项目?')) { if (confirm('确认删除此项目?')) {
afi.rpc.rmCall(afi.utils.prependPath(link.href), afi.utils.refresh); afi.rpc.rmCall(afi.utils.prependPath(link.getAttribute('href')), afi.utils.refresh);
} }
} }
isDoubleClick = false; isDoubleClick = false;
@@ -421,8 +435,7 @@ function init() {
clickTimer = null; clickTimer = null;
} }
if (afi.params.readonly) return; if (link.innerText === '../') return;
if (link.innerText === '../') return; // 不能操作上级
const newName = prompt('新名称或目标地址', link.innerText); const newName = prompt('新名称或目标地址', link.innerText);
if (newName && !afi.utils.isDupe(newName)) { if (newName && !afi.utils.isDupe(newName)) {
@@ -438,12 +451,11 @@ function init() {
}, 100); }, 100);
}); });
}); });
document.ondragstart = e => { draggingSrc = e.target.innerHTML } // 记录拖拽源 document.ondragstart = e => { draggingSrc = e.target.innerHTML }
document.ondragend = e => resetHighlight() // 清理高亮 document.ondragend = e => resetHighlight()
document.ondragover = e => { afi.utils.cancelDefault(e); return false } // 允许拖拽 document.ondragover = e => { afi.utils.cancelDefault(e); return false }
document.ondrop = e => { document.ondrop = e => {
if (afi.params.readonly) return
afi.utils.cancelDefault(e) afi.utils.cancelDefault(e)
afi.elements.dropGrid.style.display = 'none' afi.elements.dropGrid.style.display = 'none'
@@ -460,9 +472,6 @@ function init() {
draggingSrc = null draggingSrc = null
return false return false
} }
if (!afi.params.readonly) {
afi.elements.barProc.innerText = "传输: 就绪"
}
console.log('[AFI] Initialized') console.log('[AFI] Initialized')
} }

View File

@@ -10,7 +10,6 @@
css_will_be_here css_will_be_here
</style> </style>
<script> <script>
readonly = {{.Ro }}
extraPath = {{.ExtraPath }}.slice(0, -1) extraPath = {{.ExtraPath }}.slice(0, -1)
js_will_be_here js_will_be_here
</script> </script>
@@ -23,18 +22,15 @@
<span class="nav" id="nav">{{.Path}}</span> <span class="nav" id="nav">{{.Path}}</span>
<span id="path" style="display: none;" onclick="return afi.directcall.bread_click(event)">{{.Path}}</span> <span id="path" style="display: none;" onclick="return afi.directcall.bread_click(event)">{{.Path}}</span>
<div id="operations"> <div id="operations">
{{if not .Ro}} <a style="display: none;" onclick="document.getElementById('clickupload').click()"
<a onclick="document.getElementById('clickupload').click()" class="operation">上传文件</a> class="operation">上传文件</a>
<a onclick="afi.directcall.exec_touch()" class="operation">新建文件</a> <a style="display: none;" onclick="afi.directcall.exec_touch()" class="operation">新建文件</a>
<a onclick="afi.directcall.exec_mkdir()" class="operation">新建目录</a> <a style="display: none;" onclick="afi.directcall.exec_mkdir()" class="operation">新建目录</a>
<a onclick="afi.directcall.exec_mv()" class="operation">移动对象</a> <a style="display: none;" onclick="afi.directcall.exec_mv()" class="operation">移动对象</a>
<a onclick="afi.directcall.exec_rm()" class="operation">递归删除</a> <a style="display: none;" onclick="afi.directcall.exec_rm()" class="operation">递归删除</a>
<a onclick="" class="operation">登出</a> <a style="display: none;" onclick="afi.auth.logout()" class="operation">登出</a>
{{end}} <a style="display: block;" href="./login" target="_blank" rel="noopener noreferrer" class="operation">登录</a>
{{if .Ro}} <a style="user-select: none;" onclick="afi.utils.helpOn()" class="operation" id="help-toggle">帮助菜单</a>
<a onclick="" class="operation">登录</a>
{{end}}
<a onclick="afi.utils.helpOn()" class="operation" id="help-toggle">帮助菜单</a>
</div> </div>
</header> </header>