权限与功能改进
This commit is contained in:
124
README.md
124
README.md
@@ -2,23 +2,66 @@
|
||||
|
||||
本项目 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 嵌入优化
|
||||
- 性能高效, 提供真正的毫秒级响应, 用户使用体验不让位于技术审美与性能
|
||||
|
||||
* 文件/目录浏览与处理
|
||||
* 拖放上传
|
||||
* 轻量级网页用户界面, 毫秒级响应
|
||||
* 键盘导航
|
||||
* 轻量级, 且易于维护的代码库
|
||||
* 快速的 Go 语言静态服务器
|
||||
* 基于 DESCRIPT.ION 的文件注释支持
|
||||
* 可选的只读模式
|
||||
* 可选的 symlink 追踪
|
||||
* 支持 PWA
|
||||
* 多平台支持
|
||||
## 安全与不安全设计
|
||||
|
||||
考虑到此程序可能被使用的场景与便于方案选型, 笔者必须指出此程序存在的问题
|
||||
如您发现了其他可能的漏洞, 请在 issues 指出, 若属实, 笔者会将其修复或加入下方列表
|
||||
|
||||
安全建议:
|
||||
|
||||
- 强烈建议配置一个 nginx 反向代理
|
||||
- 必要时设置超时, 限制请求大小与频率
|
||||
- 完全避免使用 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 -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
95
afi.go
@@ -32,7 +32,6 @@ import (
|
||||
)
|
||||
|
||||
// rowTemplate 定义文件列表中的每一行数据结构
|
||||
// 用于在HTML模板中渲染项目的显示信息
|
||||
type rowTemplate struct {
|
||||
Name string // 文件/文件夹名称
|
||||
Href template.URL // 链接地址
|
||||
@@ -43,7 +42,6 @@ type rowTemplate struct {
|
||||
}
|
||||
|
||||
// pageTemplate 定义整个页面渲染所需的数据结构
|
||||
// 包含页面标题、路径前缀、读写模式标志以及文件和文件夹列表
|
||||
type pageTemplate struct {
|
||||
Title string // 页面标题
|
||||
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 verb = flag.Bool("verb", false, "详细输出") // 是否输出详细日志
|
||||
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 authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据") // 如果非要安全 应该用stdin注入密码防盗窃, 这个flag只能用来调试 不然服务器一个ps就泄露了
|
||||
|
||||
// rpcCall 定义RPC调用的JSON数据结构
|
||||
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
|
||||
@@ -71,8 +70,9 @@ type rpcCall struct {
|
||||
Args []string `json:"args"` // 方法参数
|
||||
}
|
||||
|
||||
var rootPath = "" // 共享目录的根路径(绝对路径)
|
||||
var rootPath = "" // 共享目录的根路径(文件系统中的绝对路径)
|
||||
var handler http.Handler // HTTP处理器, 用于处理静态文件服务
|
||||
var authTable = make(map[string]string)
|
||||
|
||||
// check 检查错误, 如果错误不为nil则panic
|
||||
// 用于简化错误处理
|
||||
@@ -91,7 +91,6 @@ func exitPath(w http.ResponseWriter, s ...interface{}) {
|
||||
// 发生panic时记录错误并返回500状态码
|
||||
log.Println("error", s, r)
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte("error"))
|
||||
} else if *verb {
|
||||
// 详细模式下记录正常日志
|
||||
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.ExtraPath = template.HTML(html.EscapeString(*extraPath))
|
||||
p.Ro = *ro
|
||||
p.Path = template.HTML(html.EscapeString(pathtext)) // 安全化路径
|
||||
if pathtext == "/" {
|
||||
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响应写入器
|
||||
// r: HTTP请求
|
||||
func doContent(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
name := query.Get("header")
|
||||
name := query.Get("footer")
|
||||
// query := r.URL.Query()
|
||||
// 此处可以接收参数
|
||||
// 如果URL不以配置的前缀开头, 重定向到正确的前缀
|
||||
if !strings.HasPrefix(r.URL.Path, *extraPath) {
|
||||
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 处理文件上传请求
|
||||
// 从multipart请求中读取文件内容并保存到指定路径
|
||||
// w: HTTP响应写入器
|
||||
// r: HTTP请求
|
||||
func upload(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkAuthRequest(w, r) {
|
||||
return
|
||||
}
|
||||
// 从header中获取目标路径
|
||||
path := r.Header.Get("afi-path")
|
||||
defer exitPath(w, "upload", path)
|
||||
@@ -352,6 +401,9 @@ func upload(w http.ResponseWriter, r *http.Request) {
|
||||
// w: HTTP响应写入器
|
||||
// r: HTTP请求
|
||||
func zipRPC(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkAuthRequest(w, r) {
|
||||
return
|
||||
}
|
||||
// 获取要打包的路径和ZIP文件名
|
||||
zipPath := r.URL.Query().Get("zipPath")
|
||||
zipName := r.URL.Query().Get("zipName")
|
||||
@@ -389,7 +441,7 @@ func zipRPC(w http.ResponseWriter, r *http.Request) {
|
||||
header, err := zip.FileInfoHeader(f)
|
||||
check(err)
|
||||
header.Name = filepath.ToSlash(rel) // 统一使用斜杠分隔
|
||||
header.Method = zip.Store // 使用存储模式(不压缩)
|
||||
header.Method = zip.Store // 使用存储模式 (不压缩)
|
||||
headerWriter, err := zipWriter.CreateHeader(header)
|
||||
check(err)
|
||||
// 打开文件并复制到ZIP中
|
||||
@@ -409,6 +461,9 @@ func zipRPC(w http.ResponseWriter, r *http.Request) {
|
||||
// w: HTTP响应写入器
|
||||
// r: HTTP请求
|
||||
func rpc(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkAuthRequest(w, r) {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
var rpc rpcCall
|
||||
defer exitPath(w, "rpc", &rpc)
|
||||
@@ -488,33 +543,37 @@ func main() {
|
||||
if flag.Parse(); len(flag.Args()) == 1 {
|
||||
rootPath = flag.Args()[0]
|
||||
} else {
|
||||
fmt.Printf("\nusage: ./afi [OPTIONS] ~/directory-to-share\n\n")
|
||||
fmt.Printf("Usage: ./afi [OPTION]... [ROOTDIR]\n\n")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
err := json.Unmarshal([]byte(*authstr), &authTable)
|
||||
check(err)
|
||||
|
||||
// 获取共享目录的绝对路径
|
||||
var err error
|
||||
rootPath, err = filepath.Abs(rootPath)
|
||||
check(err)
|
||||
|
||||
// 创建HTTP服务器
|
||||
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+"dav", upload) // TODO: webdav 接口
|
||||
http.HandleFunc(*extraPath+"auth", authState) // 仅检查登录接口(不会弹窗)
|
||||
http.HandleFunc(*extraPath+"login", authLogin) // 登录接口
|
||||
http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口
|
||||
http.HandleFunc("/", doContent) // 主内容处理接口
|
||||
// 创建静态文件服务器, 用于直接提供文件下载
|
||||
handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath)))
|
||||
|
||||
// 输出启动信息
|
||||
fmt.Printf("Agile File Indexer 是 Gossa 的分支\n")
|
||||
fmt.Printf("Agile File Indexer\n")
|
||||
fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n")
|
||||
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)
|
||||
|
||||
// 启动HTTP服务
|
||||
|
||||
141
ui/script.js
141
ui/script.js
@@ -1,4 +1,5 @@
|
||||
afi = {
|
||||
auth: {},
|
||||
consts: {},
|
||||
params: {},
|
||||
elements: {},
|
||||
@@ -9,13 +10,39 @@ afi = {
|
||||
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 = {
|
||||
readonly: readonly,
|
||||
extraPath: extraPath
|
||||
}
|
||||
|
||||
@@ -104,7 +131,6 @@ afi.utils = {
|
||||
try {
|
||||
this.getHighlight().classList.remove('highlight')
|
||||
} catch (e) {
|
||||
/* */
|
||||
}
|
||||
},
|
||||
isHelpMode: () => afi.elements.helpPanel.style.display === 'block',
|
||||
@@ -120,18 +146,37 @@ afi.utils = {
|
||||
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.send(JSON.stringify({ call, args }))
|
||||
xhr.onload = cb
|
||||
xhr.onerror = () => afi.pulsar.pulseFailure()
|
||||
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) {
|
||||
@@ -173,36 +218,33 @@ afi.uploader = {
|
||||
this.totalUploadedSize = []
|
||||
setTimeout(afi.utils.refresh, 200)
|
||||
afi.elements.barDisplay.style.display = 'none'
|
||||
//afi.elements.barProc.innerText = "传输: 就绪"
|
||||
}
|
||||
},
|
||||
|
||||
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) + '%' // 此处消除对小文件元数据上传带来的误差, 理论上是不准的
|
||||
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.upload.addEventListener('load', cbDone)
|
||||
xhr.upload.addEventListener('progress', cbUpdate)
|
||||
xhr.upload.addEventListener('error', cbErr)
|
||||
xhr.upload.id = id
|
||||
xhr.send(what)
|
||||
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) {
|
||||
if (afi.params.readonly) return
|
||||
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
|
||||
//this.totalUploadedSize[this.totalUploads] = file.size
|
||||
if (typeof upBarName !== 'undefined') {
|
||||
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() {
|
||||
afi.elements = {
|
||||
itemlinks: Array.from(document.querySelectorAll('a.item-links')),
|
||||
@@ -357,6 +367,11 @@ function init() {
|
||||
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);
|
||||
@@ -373,7 +388,6 @@ function init() {
|
||||
}
|
||||
let draggingSrc
|
||||
document.ondragenter = e => {
|
||||
if (afi.params.readonly) { return }
|
||||
afi.utils.cancelDefault(e)
|
||||
afi.utils.resetHighlight()
|
||||
if (!draggingSrc) {
|
||||
@@ -393,7 +407,7 @@ function init() {
|
||||
let isDoubleClick = false;
|
||||
|
||||
row.addEventListener('click', (e) => {
|
||||
if (afi.params.readonly || link.innerText === '../') return;
|
||||
if (link.innerText === '../') return;
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer);
|
||||
clickTimer = null;
|
||||
@@ -403,7 +417,7 @@ function init() {
|
||||
if (!isDoubleClick) {
|
||||
e.stopPropagation();
|
||||
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;
|
||||
@@ -421,8 +435,7 @@ function init() {
|
||||
clickTimer = null;
|
||||
}
|
||||
|
||||
if (afi.params.readonly) return;
|
||||
if (link.innerText === '../') return; // 不能操作上级
|
||||
if (link.innerText === '../') return;
|
||||
|
||||
const newName = prompt('新名称或目标地址', link.innerText);
|
||||
if (newName && !afi.utils.isDupe(newName)) {
|
||||
@@ -438,12 +451,11 @@ function init() {
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
document.ondragstart = e => { draggingSrc = e.target.innerHTML } // 记录拖拽源
|
||||
document.ondragend = e => resetHighlight() // 清理高亮
|
||||
document.ondragover = e => { afi.utils.cancelDefault(e); return false } // 允许拖拽
|
||||
document.ondragstart = e => { draggingSrc = e.target.innerHTML }
|
||||
document.ondragend = e => resetHighlight()
|
||||
document.ondragover = e => { afi.utils.cancelDefault(e); return false }
|
||||
|
||||
document.ondrop = e => {
|
||||
if (afi.params.readonly) return
|
||||
|
||||
afi.utils.cancelDefault(e)
|
||||
afi.elements.dropGrid.style.display = 'none'
|
||||
@@ -460,9 +472,6 @@ function init() {
|
||||
draggingSrc = null
|
||||
return false
|
||||
}
|
||||
if (!afi.params.readonly) {
|
||||
afi.elements.barProc.innerText = "传输: 就绪"
|
||||
}
|
||||
console.log('[AFI] Initialized')
|
||||
}
|
||||
|
||||
|
||||
22
ui/ui.html
22
ui/ui.html
@@ -10,7 +10,6 @@
|
||||
css_will_be_here
|
||||
</style>
|
||||
<script>
|
||||
readonly = {{.Ro }}
|
||||
extraPath = {{.ExtraPath }}.slice(0, -1)
|
||||
js_will_be_here
|
||||
</script>
|
||||
@@ -23,18 +22,15 @@
|
||||
<span class="nav" id="nav">{{.Path}}</span>
|
||||
<span id="path" style="display: none;" onclick="return afi.directcall.bread_click(event)">{{.Path}}</span>
|
||||
<div id="operations">
|
||||
{{if not .Ro}}
|
||||
<a onclick="document.getElementById('clickupload').click()" class="operation">上传文件</a>
|
||||
<a onclick="afi.directcall.exec_touch()" class="operation">新建文件</a>
|
||||
<a onclick="afi.directcall.exec_mkdir()" class="operation">新建目录</a>
|
||||
<a onclick="afi.directcall.exec_mv()" class="operation">移动对象</a>
|
||||
<a onclick="afi.directcall.exec_rm()" class="operation">递归删除</a>
|
||||
<a onclick="" class="operation">登出</a>
|
||||
{{end}}
|
||||
{{if .Ro}}
|
||||
<a onclick="" class="operation">登录</a>
|
||||
{{end}}
|
||||
<a onclick="afi.utils.helpOn()" class="operation" id="help-toggle">帮助菜单</a>
|
||||
<a style="display: none;" onclick="document.getElementById('clickupload').click()"
|
||||
class="operation">上传文件</a>
|
||||
<a style="display: none;" onclick="afi.directcall.exec_touch()" class="operation">新建文件</a>
|
||||
<a style="display: none;" onclick="afi.directcall.exec_mkdir()" 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.auth.logout()" class="operation">登出</a>
|
||||
<a style="display: block;" href="./login" target="_blank" rel="noopener noreferrer" class="operation">登录</a>
|
||||
<a style="user-select: none;" onclick="afi.utils.helpOn()" class="operation" id="help-toggle">帮助菜单</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user