From c4b7fdd67ee6e60a4d0ae262a10d171b77e9b6c0 Mon Sep 17 00:00:00 2001 From: Wang Zhiyu Date: Sat, 28 Mar 2026 22:41:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AC=A1=E6=8F=90=E4=BA=A4=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 19 +++ LICENSE | 31 ++-- README.md | 34 +++- afi.go | 417 +++++++++++++++++++++++++++++++++++++++++++++++ afi_embed.go | 36 ++++ afi_embed_dev.go | 59 +++++++ go.mod | 3 + 7 files changed, 583 insertions(+), 16 deletions(-) create mode 100644 .gitignore create mode 100644 afi.go create mode 100644 afi_embed.go create mode 100644 afi_embed_dev.go create mode 100644 go.mod diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c25678c --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +afi +afi-linux64 +afi-linux-arm +afi-linux-arm64 +afi-mac +afi-windows.exe +builds +afi.test + +afi-dev +afi-dev-linux64 +afi-dev-linux-arm +afi-dev-linux-arm64 +afi-dev-mac +afi-dev-windows.exe +afi-dev.test + +*.out +.vscode \ No newline at end of file diff --git a/LICENSE b/LICENSE index ada7254..0fab3cf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,23 @@ MIT License -Copyright (c) 2026 pluv +Copyright (c) 2018 Pierre Dubouilh -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +Modifications Copyright (c) 2026 Wang Zhiyu -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 132ba80..9ad7fe7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ -# AFI - -敏捷文件索引器 支持上下行文件传输的轻量级网络文件索引软件 \ No newline at end of file +# AFI - gossa 的个人维护分支 + + +本项目 fork 自 [Gossa, 由 Pierre Dubouilh 及 Gossa Authors 开发维护](https://github.com/pldubouilh/gossa), 并重新初始化 git. + +一个敏捷且超轻量的文件网页服务器, 无依赖且代码量极小, 易于审查. + +默认提供简洁的UI, 包含以下功能: + + * 文件/目录浏览与处理 + * 拖放上传 + * 轻量级网页 UI, 毫秒级响应 + * 文本编辑器 + * 键盘导航 + * 轻量级且无依赖的代码库 + * 快速的 Go 语言静态服务器 + * 只读模式 + * 支持 PWA + * 多平台支持 + +## 使用方法 +```sh +% ./afi --help +``` + +## 编译 +```bash +go vet && go fmt +go build -o afi # 单文件, 包括 web 资源 +go build -tags dev -o afi-dev # 使用运行目录(优先)或程序目录下的 web 资源 +``` \ No newline at end of file diff --git a/afi.go b/afi.go new file mode 100644 index 0000000..683cfab --- /dev/null +++ b/afi.go @@ -0,0 +1,417 @@ +package main + +import ( + "archive/zip" + "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" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// rowTemplate 定义文件列表中的每一行数据结构 +// 用于在HTML模板中渲染文件或文件夹的显示信息 +type rowTemplate struct { + Name string // 文件/文件夹名称 + Href template.URL // 链接地址 + Size string // 文件大小(仅文件) + Ext string // 文件扩展名(仅文件) +} + +// pageTemplate 定义整个页面渲染所需的数据结构 +// 包含页面标题、路径前缀、读写模式标志以及文件和文件夹列表 +type pageTemplate struct { + Title template.HTML // 页面标题,显示当前路径 + ExtraPath template.HTML // URL前缀路径 + Ro bool // 是否只读模式(不允许上传、重命名、移动等操作) + RowsFiles []rowTemplate // 文件列表 + RowsFolders []rowTemplate // 文件夹列表 +} + +// 命令行参数定义 +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, "只读模式(无法修改文件系统)") // 是否只读模式 + +// rpcCall 定义RPC调用的JSON数据结构 +// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和) +type rpcCall struct { + Call string `json:"call"` // 调用的方法名 + Args []string `json:"args"` // 方法参数 +} + +var rootPath = "" // 共享目录的根路径(绝对路径) +var handler http.Handler // HTTP处理器,用于处理静态文件服务 + +// check 检查错误,如果错误不为nil则panic +// 用于简化错误处理,配合defer exitPath使用 +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) + w.Write([]byte("error")) + } 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", "k", "M", "G", "T", "P", "E", "Z", "Y"}[u] + } + b = b / 1024 + u++ + } +} + +// 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 { return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name()) }) + + // 确保路径以斜杠结尾 + if !strings.HasSuffix(path, "/") { + path += "/" + } + + // 构建页面标题,显示当前路径 + title := "/" + strings.TrimPrefix(path, *extraPath) + p := pageTemplate{} + // 如果不是根目录,添加返回上级目录的链接 + if path != *extraPath { + p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "../", "", "folder"}) + } + p.ExtraPath = template.HTML(html.EscapeString(*extraPath)) + p.Ro = *ro + p.Title = template.HTML(html.EscapeString(title)) + + // 遍历目录中的每个条目 + 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) + } + + // 根据类型添加到不同的列表 + if el.IsDir() { + row := rowTemplate{name + "/", template.URL(href), "4.0k", "folder"} + p.RowsFolders = append(p.RowsFolders, row) + } else { + // 提取文件扩展名 + sl := strings.Split(name, ".") + ext := strings.ToLower(sl[len(sl)-1]) + row := rowTemplate{name, template.URL(href), humanize(el.Size()), ext} + 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) { + // 如果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) + } +} + +// upload 处理文件上传请求 +// 从multipart请求中读取文件内容并保存到指定路径 +// w: HTTP响应写入器 +// r: HTTP请求 +func upload(w http.ResponseWriter, r *http.Request) { + // 从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) { + // 获取要打包的路径和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) { + 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 "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("\nusage: ./afi [OPTIONS] ~/directory-to-share\n\n") + flag.PrintDefaults() + os.Exit(1) + } + + // 获取共享目录的绝对路径 + 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+"post", upload) // 文件上传接口 + } + 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("AFI 已启动, 根目录为 %s\n", rootPath) + fmt.Printf("详细输出: %t, 符号链接跟随: %t, 只读模式: %t, 跳过隐藏文件: %t\n", *verb, *symlinks, *ro, *skipHidden) + fmt.Printf("正在监听: http://%s:%s%s\n", *host, *port, *extraPath) + + // 启动HTTP服务 + if err = server.ListenAndServe(); err != http.ErrServerClosed { + check(err) + } +} diff --git a/afi_embed.go b/afi_embed.go new file mode 100644 index 0000000..e0b0ef7 --- /dev/null +++ b/afi_embed.go @@ -0,0 +1,36 @@ +//go:build !dev + +package main + +import ( + _ "embed" + "encoding/base64" + "html/template" + "strings" +) + +//go:embed ui/script.js +var scriptJs string + +//go:embed ui/style.css +var styleCss string + +//go:embed ui/favicon.svg +var faviconSvg []byte + +//go:embed ui/ui.html +var uiTmpl string + +var tmpl *template.Template + +func init() { + t := strings.Replace(uiTmpl, "css_will_be_here", styleCss, 1) + t = strings.Replace(t, "js_will_be_here", scriptJs, 1) + t = strings.Replace(t, "favicon_will_be_here", base64.StdEncoding.EncodeToString(faviconSvg), 2) + + var err error + tmpl, err = template.New("").Parse(t) + if err != nil { + panic(err) + } +} diff --git a/afi_embed_dev.go b/afi_embed_dev.go new file mode 100644 index 0000000..f18bd4f --- /dev/null +++ b/afi_embed_dev.go @@ -0,0 +1,59 @@ +//go:build dev + +package main + +import ( + "encoding/base64" + "html/template" + "os" + "path/filepath" + "strings" +) + +var tmpl *template.Template + +func init() { + // 开发模式下从当前目录读取 UI 文件 + uiDir := "ui" + + // 如果当前目录没有 ui,尝试从可执行文件目录查找 + if _, err := os.Stat(uiDir); os.IsNotExist(err) { + execPath, err := os.Executable() + if err != nil { + panic(err) + } + uiDir = filepath.Join(filepath.Dir(execPath), "ui") + } + + // 读取 UI 文件 + styleCSS, err := os.ReadFile(filepath.Join(uiDir, "style.css")) + if err != nil { + panic(err) + } + + scriptJS, err := os.ReadFile(filepath.Join(uiDir, "script.js")) + if err != nil { + panic(err) + } + + faviconSVG, err := os.ReadFile(filepath.Join(uiDir, "favicon.svg")) + if err != nil { + panic(err) + } + + uiTmpl, err := os.ReadFile(filepath.Join(uiDir, "ui.html")) + if err != nil { + panic(err) + } + + // 组装模板 + t := strings.Replace(string(uiTmpl), "css_will_be_here", string(styleCSS), 1) + t = strings.Replace(t, "js_will_be_here", string(scriptJS), 1) + t = strings.Replace(t, "favicon_will_be_here", base64.StdEncoding.EncodeToString(faviconSVG), 2) + + var parseErr error + tmpl, parseErr = template.New("").Parse(t) + if parseErr != nil { + panic(parseErr) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..59fffb6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module afi + +go 1.23.0