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" ) // 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, "跟随符号链接 \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 title = flag.String("title", "%PATH%", "页面标题, 用%PATH%指代完整路径, %ITEM%指代末端文件/目录名, 不会泄漏根目录目录名") var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据") // 如果非要安全 应该用stdin注入密码防盗窃, 这个flag只能用来调试 不然服务器一个ps就泄露了 // 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++ } } // lookupDesc 返回指定文件在 DESCRIPT.ION 中的注释内容 func lookupDesc(fullPath string) string { // 1. 获取绝对路径并提取目录和文件名 absPath, err := filepath.Abs(fullPath) if err != nil { return "" } dir := filepath.Dir(absPath) fileName := filepath.Base(absPath) if fileName == "DESCRIPT.ION" { return "备注元数据" } // 2. 尝试打开该目录下的 DESCRIPT.ION (不区分大小写) // 在 Windows 下直接打开即可,在 Linux 下可能需要遍历目录匹配文件名 descFilePath := filepath.Join(dir, "DESCRIPT.ION") file, err := os.Open(descFilePath) if err != nil { return "" } defer file.Close() // 3. 逐行扫描文件内容 scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var targetName, description string // DESCRIPT.ION 格式处理: // 如果文件名包含空格,通常会被双引号包裹,例如: "My Photo.jpg" This is a photo if strings.HasPrefix(line, "\"") { endQuoteIndex := strings.Index(line[1:], "\"") if endQuoteIndex != -1 { targetName = line[1 : endQuoteIndex+1] description = strings.TrimSpace(line[endQuoteIndex+2:]) } } else { // 普通格式:文件名 描述 (空格分隔) parts := strings.SplitN(line, " ", 2) targetName = parts[0] if len(parts) > 1 { description = strings.TrimSpace(parts[1]) } } // 4. 匹配文件名(不区分大小写,符合该标准的一贯做法) if strings.EqualFold(targetName, fileName) { // 部分实现会在描述末尾包含控制字符 \x04,需要清理 description = strings.Split(description, "\x04")[0] return strings.TrimSpace(description) } } return "" } // 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) // 按文件名(不区分大小写)排序 sort.Slice(files, func(i, j int) bool { nameI := files[i].Name() nameJ := files[j].Name() // 1. 特殊逻辑:处理 DESCRIPT.ION // 如果 i 是目标文件,且 j 不是,则 i 应该在后 (返回 false) if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") { return false } // 如果 j 是目标文件,且 i 不是,则 i 应该在前 (返回 true) if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") { return true } // 2. 普通逻辑:不区分大小写的字母排序 return strings.ToLower(nameI) < strings.ToLower(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 _, el := range files { info, errInfo := el.Info() el, err := os.Stat(fullPath + "/" + el.Name()) if err != nil || errInfo != nil { log.Println("error - cant stat a file", err) continue } // 跳过隐藏文件(如果配置了跳过) if *skipHidden && strings.HasPrefix(el.Name(), ".") { continue } // 跳过符号链接(如果不允许跟随) if !*symlinks && info.Mode()&os.ModeSymlink != 0 { continue } // URL编码文件名, 避免特殊字符问题 href := url.PathEscape(el.Name()) name := el.Name() // 处理文件夹链接的斜杠问题 if el.IsDir() && strings.HasPrefix(href, "/") { href = strings.Replace(href, "/", "", 1) } var desc string = lookupDesc(fullPath + "/" + el.Name()) // 根据类型添加到不同的列表 if el.IsDir() { row := rowTemplate{name + "/", template.URL(href), "4.0kB", "folder", desc, "inode/directory"} p.RowsFolders = append(p.RowsFolders, row) } else { // 提取文件扩展名 ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") filemime := mime.TypeByExtension("." + ext) filemime, _, _ = mime.ParseMediaType(filemime) // 避免mime瞎猜文档编码 if filemime == "" { if name == "DESCRIPT.ION" { filemime = "text/plain" } else { filemime = "unknown/unknown" } } row := rowTemplate{name, template.URL(href), humanize(el.Size()), ext, desc, 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 { // 构建完整路径并移除URL前缀 joined := filepath.Join(rootPath, strings.TrimPrefix(p, *extraPath)) fp, err := filepath.Abs(joined) sl, _ := filepath.EvalSymlinks(fp) // 评估符号链接的实际路径 // 安全检查: // 1. 获取绝对路径是否出错 // 2. 路径是否以rootPath开头(防止路径遍历) // 3. 如果跳过隐藏文件, 路径中是否包含隐藏目录 // 4. 如果不允许符号链接, 符号链接是否指向rootPath外 if err != nil || !strings.HasPrefix(fp, rootPath) || *skipHidden && strings.Contains(p, "/.") || !*symlinks && len(sl) > 0 && !strings.HasPrefix(sl, rootPath) { panic(errors.New("invalid path")) } return fp } func main() { // 解析命令行参数 if flag.Parse(); 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) // 文件上传接口 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\n") fmt.Printf("AFI 是 Gossa 的增强分支, 但是并不完全向下兼容原版 Gossa 的参数\n") fmt.Printf("AFI 已启动, 根目录为 %s\n", rootPath) 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服务 if err = server.ListenAndServe(); err != http.ErrServerClosed { check(err) } }