改进
This commit is contained in:
173
afi.go
173
afi.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -26,35 +28,41 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rowTemplate 定义文件列表中的每一行数据结构
|
||||
// 用于在HTML模板中渲染文件或文件夹的显示信息
|
||||
// 用于在HTML模板中渲染项目的显示信息
|
||||
type rowTemplate struct {
|
||||
Name string // 文件/文件夹名称
|
||||
Href template.URL // 链接地址
|
||||
Size string // 文件大小(仅文件)
|
||||
Ext string // 文件扩展名(仅文件)
|
||||
Size string // 项目大小
|
||||
Ext string // 项目扩展名 (文件夹是folder)
|
||||
Desc string // 项目描述
|
||||
Mime string // 项目mime
|
||||
}
|
||||
|
||||
// pageTemplate 定义整个页面渲染所需的数据结构
|
||||
// 包含页面标题、路径前缀、读写模式标志以及文件和文件夹列表
|
||||
type pageTemplate struct {
|
||||
Title template.HTML // 页面标题,显示当前路径
|
||||
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 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数据结构
|
||||
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
|
||||
@@ -64,10 +72,10 @@ type rpcCall struct {
|
||||
}
|
||||
|
||||
var rootPath = "" // 共享目录的根路径(绝对路径)
|
||||
var handler http.Handler // HTTP处理器,用于处理静态文件服务
|
||||
var handler http.Handler // HTTP处理器, 用于处理静态文件服务
|
||||
|
||||
// check 检查错误,如果错误不为nil则panic
|
||||
// 用于简化错误处理,配合defer exitPath使用
|
||||
// check 检查错误, 如果错误不为nil则panic
|
||||
// 用于简化错误处理
|
||||
func check(e error) {
|
||||
if e != nil {
|
||||
panic(e)
|
||||
@@ -75,7 +83,7 @@ func check(e error) {
|
||||
}
|
||||
|
||||
// exitPath 延迟执行的错误恢复和日志记录函数
|
||||
// 用于捕获panic并返回500错误,同时记录日志
|
||||
// 用于捕获panic并返回500错误, 同时记录日志
|
||||
// w: HTTP响应写入器
|
||||
// s: 日志信息参数
|
||||
func exitPath(w http.ResponseWriter, s ...interface{}) {
|
||||
@@ -100,15 +108,76 @@ func humanize(bytes int64) string {
|
||||
for {
|
||||
if b < 1024 {
|
||||
// 根据当前数量级选择合适的单位
|
||||
return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "k", "M", "G", "T", "P", "E", "Z", "Y"}[u]
|
||||
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: 目录的绝对路径
|
||||
@@ -117,25 +186,47 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
|
||||
// 读取目录内容
|
||||
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()) })
|
||||
|
||||
// 按文件名(不区分大小写)排序
|
||||
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 += "/"
|
||||
}
|
||||
|
||||
// 构建页面标题,显示当前路径
|
||||
title := "/" + strings.TrimPrefix(path, *extraPath)
|
||||
// 构建当前路径
|
||||
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{"../", "../", "", "folder"})
|
||||
p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"})
|
||||
}
|
||||
p.ExtraPath = template.HTML(html.EscapeString(*extraPath))
|
||||
p.Ro = *ro
|
||||
p.Title = template.HTML(html.EscapeString(title))
|
||||
|
||||
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()
|
||||
@@ -154,7 +245,7 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
|
||||
continue
|
||||
}
|
||||
|
||||
// URL编码文件名,避免特殊字符问题
|
||||
// URL编码文件名, 避免特殊字符问题
|
||||
href := url.PathEscape(el.Name())
|
||||
name := el.Name()
|
||||
|
||||
@@ -163,15 +254,25 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
|
||||
href = strings.Replace(href, "/", "", 1)
|
||||
}
|
||||
|
||||
var desc string = lookupDesc(fullPath + "/" + el.Name())
|
||||
|
||||
// 根据类型添加到不同的列表
|
||||
if el.IsDir() {
|
||||
row := rowTemplate{name + "/", template.URL(href), "4.0k", "folder"}
|
||||
row := rowTemplate{name + "/", template.URL(href), "4.0kB", "folder", desc, "inode/directory"}
|
||||
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}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -180,7 +281,7 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Add("Content-Encoding", "gzip")
|
||||
// 使用gzip压缩响应内容,提高传输效率
|
||||
// 使用gzip压缩响应内容, 提高传输效率
|
||||
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
|
||||
check(err)
|
||||
defer gz.Close()
|
||||
@@ -191,11 +292,14 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
|
||||
}
|
||||
|
||||
// doContent 处理所有内容请求的核心函数
|
||||
// 根据请求路径判断是目录还是文件,目录则列出文件列表,文件则提供下载
|
||||
// 根据请求路径判断是目录还是文件, 目录则列出文件列表, 文件则提供下载
|
||||
// w: HTTP响应写入器
|
||||
// r: HTTP请求
|
||||
func doContent(w http.ResponseWriter, r *http.Request) {
|
||||
// 如果URL不以配置的前缀开头,重定向到正确的前缀
|
||||
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
|
||||
@@ -209,7 +313,7 @@ func doContent(w http.ResponseWriter, r *http.Request) {
|
||||
stat, errStat := os.Stat(fullPath)
|
||||
check(errStat)
|
||||
|
||||
// 如果是目录,显示文件列表;如果是文件,交给静态文件服务器处理
|
||||
// 如果是目录, 显示文件列表;如果是文件, 交给静态文件服务器处理
|
||||
if stat.IsDir() {
|
||||
replyList(w, r, fullPath, path)
|
||||
} else {
|
||||
@@ -261,7 +365,7 @@ func zipRPC(w http.ResponseWriter, r *http.Request) {
|
||||
zipWriter := zip.NewWriter(w)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// 遍历目录树,将文件添加到ZIP中
|
||||
// 遍历目录树, 将文件添加到ZIP中
|
||||
err = filepath.Walk(zipFullPath, func(path string, f fs.FileInfo, err error) error {
|
||||
check(err)
|
||||
// 跳过目录(目录本身不加入ZIP)
|
||||
@@ -317,6 +421,9 @@ func rpc(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 根据调用的方法执行相应操作
|
||||
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)
|
||||
@@ -355,7 +462,7 @@ func rpc(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// enforcePath 确保请求的路径是安全的
|
||||
// 防止路径遍历攻击,确保访问路径在共享目录内
|
||||
// 防止路径遍历攻击, 确保访问路径在共享目录内
|
||||
// p: 请求的路径
|
||||
// 返回: 安全的绝对路径
|
||||
func enforcePath(p string) string {
|
||||
@@ -367,8 +474,8 @@ func enforcePath(p string) string {
|
||||
// 安全检查:
|
||||
// 1. 获取绝对路径是否出错
|
||||
// 2. 路径是否以rootPath开头(防止路径遍历)
|
||||
// 3. 如果跳过隐藏文件,路径中是否包含隐藏目录
|
||||
// 4. 如果不允许符号链接,符号链接是否指向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"))
|
||||
}
|
||||
@@ -401,7 +508,7 @@ func main() {
|
||||
}
|
||||
http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口
|
||||
http.HandleFunc("/", doContent) // 主内容处理接口
|
||||
// 创建静态文件服务器,用于直接提供文件下载
|
||||
// 创建静态文件服务器, 用于直接提供文件下载
|
||||
handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath)))
|
||||
|
||||
// 输出启动信息
|
||||
|
||||
Reference in New Issue
Block a user