初次提交并重新初始化
This commit is contained in:
417
afi.go
Normal file
417
afi.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user