Files
AFI/afi.go
2026-04-26 04:45:48 +08:00

604 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"archive/zip"
"bufio"
"compress/gzip"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"hash"
"html"
"html/template"
"io"
"io/fs"
"log"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
const ver = "26.4.26"
// rowTemplate 定义文件列表中的每一行数据结构
type rowTemplate struct {
Name string // 文件/文件夹名称
Href template.URL // 链接地址
Size string // 项目大小
Ext string // 项目扩展名 (文件夹是folder)
Desc string // 项目描述
Mime string // 项目mime
}
// pageTemplate 定义整个页面渲染所需的数据结构
type pageTemplate struct {
Title string // 页面标题
Path template.HTML // 页面路径
ExtraPath template.HTML // URL前缀路径
Ro bool // 是否只读模式(不允许上传、重命名、移动等操作)
RowsFiles []rowTemplate // 文件列表
RowsFolders []rowTemplate // 文件夹列表
TimeStamp string //时间戳
}
// 命令行参数定义
var host = flag.String("h", "127.0.0.1", "监听的主机地址") // 监听的主机地址
var port = flag.String("p", "8001", "监听的端口") // 监听的端口
var extraPath = flag.String("prefix", "/", "afi 的 URL 访问路径前缀, 例如 /afi/ (斜杠很重要)") // URL前缀路径
var symlinks = flag.Bool("symlinks", false, "跟随符号链接 警告: 启用后符号链接可能会跳出已定义的\"根目录\"") // 是否跟随符号链接
var verb = flag.Bool("verb", false, "详细输出") // 是否输出详细日志
var skipHidden = flag.Bool("k", false, "跳过隐藏文件") // 是否跳过隐藏文件(以.开头)
// var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式
var title = flag.String("title", "%PATH%", "页面标题, 用 %PATH% 指代完整路径, %ITEM% 指代末端文件/目录名, 不会泄露根目录目录名")
var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据 (也可以用环境变量 AFI_AUTH 设置, AFI_AUTH 是最优先且最安全的)") // 这个flag只能用来调试 不然服务器一个ps就泄露了
var dav = flag.Bool("dav", true, "启用 WebDAV 接口")
// rpcCall 定义RPC调用的JSON数据结构
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
type rpcCall struct {
Call string `json:"call"` // 调用的方法名
Args []string `json:"args"` // 方法参数
}
var rootPath = "" // 共享目录的根路径(文件系统中的绝对路径)
var handler http.Handler // HTTP处理器, 用于处理静态文件服务
var authTable = make(map[string]string)
// check 检查错误, 如果错误不为nil则panic
// 用于简化错误处理
func check(e error) {
if e != nil {
panic(e)
}
}
// exitPath 延迟执行的错误恢复和日志记录函数
// 用于捕获panic并返回500错误, 同时记录日志
// w: HTTP响应写入器
// s: 日志信息参数
func exitPath(w http.ResponseWriter, s ...interface{}) {
if r := recover(); r != nil {
// 发生panic时记录错误并返回500状态码
log.Println("error", s, r)
w.WriteHeader(500)
} else if *verb {
// 详细模式下记录正常日志
log.Println(s...)
}
}
// humanize 将字节数转换为人类可读的格式
// 例如: 1024 -> "1.0k", 1048576 -> "1.0M"
// bytes: 文件大小(字节)
// 返回: 格式化后的字符串(如"1.5M"
func humanize(bytes int64) string {
b := float64(bytes)
u := 0
for {
if b < 1024 {
// 根据当前数量级选择合适的单位
return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}[u]
}
b = b / 1024
u++
}
}
// parseDescriptIon 只打开一次 DESCRIPT.ION 并返回 map大幅提升目录列表性能
func parseDescriptIon(dirPath string) map[string]string {
descMap := make(map[string]string)
descFilePath := filepath.Join(dirPath, "DESCRIPT.ION")
// 默认描述, 可被覆写
descMap["DESCRIPT.ION"] = "备注元数据"
descMap["README.md"] = "自述文件"
descMap["LICENSE"] = "许可证"
descMap[".gitignore"] = "Git/文件忽略模式"
descMap[".git"] = "Git/裸仓库目录"
if file, err := os.Open(descFilePath); err == nil {
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var targetName, description string
// 处理带双引号的文件名格式,例如:"My Photo.jpg" This is a description
if strings.HasPrefix(line, `"`) {
end := strings.Index(line[1:], `"`)
if end != -1 {
targetName = line[1 : end+1]
description = strings.TrimSpace(line[end+2:])
}
} else {
// 普通格式filename description
parts := strings.SplitN(line, " ", 2)
targetName = parts[0]
if len(parts) > 1 {
description = strings.TrimSpace(parts[1])
}
}
if targetName != "" {
// 清理可能的控制字符
description = strings.Split(description, "\x04")[0]
// 严格大小写:去掉可能的引号后,直接使用原始名称作为 key
cleanName := strings.Trim(targetName, `"`)
descMap[cleanName] = strings.TrimSpace(description)
}
}
}
return descMap
}
// replyList 生成目录列表页面并返回
// 读取指定目录下的文件和文件夹, 构建页面模板数据并渲染
// w: HTTP响应写入器
// r: HTTP请求
// fullPath: 目录的绝对路径
// path: 请求的URL路径
func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string) {
// 读取目录内容
files, err := os.ReadDir(fullPath)
check(err)
// 只解析一次 DESCRIPT.ION大幅提升性能
descMap := parseDescriptIon(fullPath)
// 按文件名(不区分大小写)排序
sort.Slice(files, func(i, j int) bool {
nameI := files[i].Name()
nameJ := files[j].Name()
// DESCRIPT.ION 始终放在最后
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
})
// 确保路径以斜杠结尾
if !strings.HasSuffix(path, "/") {
path += "/"
}
// 构建当前路径
pathtext := "/" + strings.TrimPrefix(path, *extraPath) // 删除extraPath前缀
p := pageTemplate{}
now := time.Now().UTC()
p.TimeStamp = now.Format(time.RFC3339)
// 如果不是根目录, 添加返回上级目录的链接
if path != *extraPath {
p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"})
}
p.ExtraPath = template.HTML(html.EscapeString(*extraPath))
p.Path = template.HTML(html.EscapeString(pathtext)) // 安全化路径
if pathtext == "/" {
p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", "/", -1)
} else {
p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", filepath.Base(fullPath), -1)
}
// 遍历目录中的每个条目
// 遍历目录中的每个条目
for _, entry := range files {
name := entry.Name()
// 跳过隐藏文件
if *skipHidden && strings.HasPrefix(name, ".") {
continue
}
// 跳过符号链接(如果不允许跟随)
if !*symlinks && entry.Type()&os.ModeSymlink != 0 {
continue
}
// 获取文件信息(只对文件调用 Info文件夹不需要
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.Replace(href, "/", "", 1)
}
desc := descMap[name]
// 根据类型添加到不同的列表
if entry.IsDir() {
row := rowTemplate{
Name: html.EscapeString(name + "/"),
Href: template.URL(href),
Size: "4.0kB",
Ext: "folder",
Desc: html.EscapeString(desc),
Mime: "inode/directory",
}
p.RowsFolders = append(p.RowsFolders, row)
} else {
// 文件:使用 info.Size()
ext := strings.TrimPrefix(filepath.Ext(name), ".")
filemime := mime.TypeByExtension("." + ext)
filemime, _, _ = mime.ParseMediaType(filemime)
if filemime == "" {
if name == "DESCRIPT.ION" {
filemime = "text/plain"
} else {
filemime = "unknown/unknown"
}
}
row := rowTemplate{
Name: html.EscapeString(name),
Href: template.URL(href),
Size: humanize(info.Size()),
Ext: html.EscapeString(ext),
Desc: html.EscapeString(desc),
Mime: filemime,
}
p.RowsFiles = append(p.RowsFiles, row)
}
}
// 检查客户端是否支持gzip压缩
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Type", "text/html")
w.Header().Add("Content-Encoding", "gzip")
// 使用gzip压缩响应内容, 提高传输效率
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
check(err)
defer gz.Close()
tmpl.Execute(gz, p)
} else {
tmpl.Execute(w, p)
}
}
// doContent 处理所有内容请求的核心函数
// 根据请求路径判断是目录还是文件, 目录则列出文件列表, 文件则提供下载
// w: HTTP响应写入器
// r: HTTP请求
func doContent(w http.ResponseWriter, r *http.Request) {
// query := r.URL.Query()
// 此处可以接收参数
// 如果URL不以配置的前缀开头, 重定向到正确的前缀
if !strings.HasPrefix(r.URL.Path, *extraPath) {
http.Redirect(w, r, *extraPath, http.StatusFound)
return
}
// 解码URL中的HTML实体
path := html.UnescapeString(r.URL.Path)
defer exitPath(w, "get content", path)
// 获取安全的绝对路径
fullPath := enforcePath(path)
stat, errStat := os.Stat(fullPath)
check(errStat)
// 如果是目录, 显示文件列表;如果是文件, 交给静态文件服务器处理
if stat.IsDir() {
replyList(w, r, fullPath, path)
} else {
handler.ServeHTTP(w, r)
}
}
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)
path, err := url.PathUnescape(path)
check(err)
// 创建multipart reader解析上传的文件
reader, err := r.MultipartReader()
check(err)
part, err := reader.NextPart()
if err != nil && err != io.EOF {
check(err)
}
// 创建目标文件
dst, err := os.Create(enforcePath(path))
check(err)
// 将上传内容复制到目标文件
io.Copy(dst, part)
w.Write([]byte("ok"))
}
// zipRPC 处理ZIP打包下载请求
// 将指定目录或文件打包成ZIP并下载
// 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")
defer exitPath(w, "zip", zipPath)
zipFullPath := enforcePath(zipPath)
_, err := os.Lstat(zipFullPath)
check(err)
// 设置下载响应头
w.Header().Add("Content-Disposition", "attachment; filename=\""+zipName+".zip\"")
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
// 遍历目录树, 将文件添加到ZIP中
err = filepath.Walk(zipFullPath, func(path string, f fs.FileInfo, err error) error {
check(err)
// 跳过目录目录本身不加入ZIP
if f.IsDir() {
return nil
}
// 计算相对路径作为ZIP内的路径
rel, err := filepath.Rel(zipFullPath, path)
check(err)
// 跳过隐藏文件
if *skipHidden && (strings.HasPrefix(rel, ".") || strings.HasPrefix(f.Name(), ".")) {
return nil
}
// 不允许打包符号链接
if f.Mode()&os.ModeSymlink != 0 {
panic(errors.New("symlink not allowed in zip downloads"))
}
// 创建ZIP文件头
header, err := zip.FileInfoHeader(f)
check(err)
header.Name = filepath.ToSlash(rel) // 统一使用斜杠分隔
header.Method = zip.Store // 使用存储模式 (不压缩)
headerWriter, err := zipWriter.CreateHeader(header)
check(err)
// 打开文件并复制到ZIP中
file, err := os.Open(path)
check(err)
defer file.Close()
_, err = io.Copy(headerWriter, file)
check(err)
return nil
})
check(err)
}
// rpc 处理RPC请求
// 支持创建目录、移动/重命名、删除文件、计算校验和等操作
// 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)
// 读取并解析JSON请求体
bodyBytes, err := io.ReadAll(r.Body)
check(err)
json.Unmarshal(bodyBytes, &rpc)
ret := []byte("ok")
// 根据调用的方法执行相应操作
switch rpc.Call {
case "touch":
// 创建目录(包括所有父目录)
_, err = os.OpenFile(enforcePath(rpc.Args[0]), os.O_RDONLY|os.O_CREATE, 0777)
case "mkdirp":
// 创建目录(包括所有父目录)
err = os.MkdirAll(enforcePath(rpc.Args[0]), os.ModePerm)
case "mv":
// 移动或重命名文件/目录
err = os.Rename(enforcePath(rpc.Args[0]), enforcePath(rpc.Args[1]))
case "rm":
// 删除文件或目录(递归删除)
err = os.RemoveAll(enforcePath(rpc.Args[0]))
case "sum":
// 计算文件的校验和md5, sha1, sha256, sha512
file, err := os.Open(enforcePath(rpc.Args[0]))
check(err)
var hash hash.Hash
// 根据参数选择哈希算法
switch rpc.Args[1] {
case "md5":
hash = md5.New()
case "sha1":
hash = sha1.New()
case "sha256":
hash = sha256.New()
case "sha512":
hash = sha512.New()
}
_, err = io.Copy(hash, file)
check(err)
checksum := hash.Sum(nil)
// 将哈希值转换为十六进制字符串
ret = make([]byte, hex.EncodedLen(len(checksum)))
hex.Encode(ret, checksum)
}
check(err)
w.Write(ret)
}
// enforcePath 确保请求的路径是安全的
// 防止路径遍历攻击, 确保访问路径在共享目录内
// p: 请求的路径
// 返回: 安全的绝对路径
func enforcePath(p string) string {
joined := filepath.Join(rootPath, strings.TrimPrefix(p, *extraPath))
fp := filepath.Clean(joined)
if !strings.HasPrefix(fp, rootPath) || (*skipHidden && strings.Contains(p, "/.")) {
panic(errors.New("invalid path"))
}
if *symlinks {
if sl, err := filepath.EvalSymlinks(fp); err == nil && !strings.HasPrefix(sl, rootPath) {
panic(errors.New("invalid path"))
}
}
return fp
}
func main() {
// 解析命令行参数
flag.Parse()
if envAuth := os.Getenv("AFI_AUTH"); envAuth != "" {
*authstr = envAuth
}
if len(flag.Args()) == 1 {
rootPath = flag.Args()[0]
} else {
fmt.Printf("Usage: ./afi [OPTION]... [ROOTDIR]\n\n")
flag.PrintDefaults()
os.Exit(1)
}
err := json.Unmarshal([]byte(*authstr), &authTable)
check(err)
// 获取共享目录的绝对路径
rootPath, err = filepath.Abs(rootPath)
check(err)
// 创建HTTP服务器
server := &http.Server{Addr: *host + ":" + *port, Handler: handler}
// 注册路由处理器
http.HandleFunc(*extraPath+"rpc", rpc) // RPC接口 (增删移与校验和)
http.HandleFunc(*extraPath+"post", upload) // 文件上传接口
if *dav {
http.HandleFunc(*extraPath+"dav/", webdavHandler) // WebDAV 接口
}
http.HandleFunc(*extraPath+"auth", authState) // 仅检查登录接口(不会弹窗)
http.HandleFunc(*extraPath+"login", authLogin) // 登录接口
http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口
http.HandleFunc("/", doContent) // 主内容处理接口
// 创建静态文件服务器, 用于直接提供文件下载
handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath)))
// 输出启动信息
fmt.Printf("Agile File Interface 版本 %s\n", ver)
fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n")
fmt.Printf("AFI 已启动, 根目录为 %s\n", rootPath)
fmt.Printf("详细输出: %t, 符号链接跟随: %t, 跳过隐藏文件: %t, WebDAV: %t\n", *verb, *symlinks, *skipHidden, *dav)
fmt.Printf("身份验证信息: %s\n", *authstr)
fmt.Printf("正在监听: http://%s:%s%s\n", *host, *port, *extraPath)
// 启动HTTP服务
if err = server.ListenAndServe(); err != http.ErrServerClosed {
check(err)
}
}