Files
AFI/afi.go
2026-04-02 07:45:43 +08:00

525 lines
16 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"
)
// rowTemplate 定义文件列表中的每一行数据结构
// 用于在HTML模板中渲染项目的显示信息
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%指代末端文件/目录名, 不会泄漏根目录目录名")
// rpcCall 定义RPC调用的JSON数据结构
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
type rpcCall struct {
Call string `json:"call"` // 调用的方法名
Args []string `json:"args"` // 方法参数
}
var rootPath = "" // 共享目录的根路径(绝对路径)
var handler http.Handler // HTTP处理器, 用于处理静态文件服务
// 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)
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", "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.Ro = *ro
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()
name := query.Get("header")
name := query.Get("footer")
// 如果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 "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("\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)
}
}