初次提交并重新初始化

This commit is contained in:
2026-03-28 22:41:47 +08:00
parent 76fa3db2eb
commit c4b7fdd67e
7 changed files with 583 additions and 16 deletions

19
.gitignore vendored Normal file
View File

@@ -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

31
LICENSE
View File

@@ -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.

View File

@@ -1,3 +1,31 @@
# AFI
# 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 资源
```

417
afi.go Normal file
View File

@@ -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)
}
}

36
afi_embed.go Normal file
View File

@@ -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)
}
}

59
afi_embed_dev.go Normal file
View File

@@ -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)
}
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module afi
go 1.23.0