权限与功能改进
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.
|
本项目 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
95
afi.go
@@ -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服务
|
||||||
|
|||||||
141
ui/script.js
141
ui/script.js
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
ui/ui.html
22
ui/ui.html
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user