lolly/internal/handler/autoindex.go
xfy 26d62c9fcd refactor(handler): improve autoindex code quality
Use fmt.Fprintf directly on buffer instead of buf.WriteString(fmt.Sprintf(...)),
handle dir.Close error in defer, use blank identifier for unused parameter,
use range-over-int, and remove trailing blank line.

💘 Generated with Crush

Assisted-by: GLM 5.1 via Crush <crush@charm.land>
2026-04-30 16:23:34 +08:00

280 lines
7.0 KiB
Go
Raw Permalink 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 handler 提供 HTTP 请求处理功能。
//
// 该文件实现目录列表autoindex功能类似 nginx 的 autoindex 模块。
// 支持三种输出格式HTML、JSON、XML。
//
// 作者xfy
package handler
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"html"
"net/url"
"os"
"sort"
"strings"
"time"
"github.com/valyala/fasthttp"
)
// AutoIndexConfig 目录列表配置。
type AutoIndexConfig struct {
Format string // 输出格式html、json、xml
Localtime bool // 使用本地时间(默认 GMT
ExactSize bool // 精确大小(默认人类可读)
}
// dirEntry 目录条目信息。
type dirEntry struct {
Name string
IsDir bool
Size int64
ModTime time.Time
}
// GenerateAutoIndex 生成目录列表响应。
//
// 参数:
// - ctx: fasthttp 请求上下文
// - dirPath: 目录路径
// - reqPath: 请求 URI 路径
// - config: 配置选项
//
// 返回值:
// - bool: 是否成功生成响应
func GenerateAutoIndex(ctx *fasthttp.RequestCtx, dirPath, reqPath string, config AutoIndexConfig) bool {
// 读取目录
entries, err := readDirectory(dirPath)
if err != nil {
return false
}
// 排序:目录优先,然后按名称排序
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir != entries[j].IsDir {
return entries[i].IsDir // 目录排在前面
}
return entries[i].Name < entries[j].Name
})
// 根据格式生成响应
switch config.Format {
case "json":
generateJSONIndex(ctx, reqPath, entries)
case "xml":
generateXMLIndex(ctx, reqPath, entries)
default:
generateHTMLIndex(ctx, reqPath, entries, config)
}
return true
}
// readDirectory 读取目录内容。
func readDirectory(dirPath string) ([]dirEntry, error) {
dir, err := os.Open(dirPath)
if err != nil {
return nil, err
}
defer func() { _ = dir.Close() }()
infos, err := dir.Readdir(-1)
if err != nil {
return nil, err
}
entries := make([]dirEntry, 0, len(infos))
for _, info := range infos {
name := info.Name()
// 跳过隐藏文件(以 . 开头)
if strings.HasPrefix(name, ".") {
continue
}
entries = append(entries, dirEntry{
Name: name,
IsDir: info.IsDir(),
Size: info.Size(),
ModTime: info.ModTime(),
})
}
return entries, nil
}
// generateHTMLIndex 生成 HTML 格式的目录列表。
func generateHTMLIndex(ctx *fasthttp.RequestCtx, reqPath string, entries []dirEntry, config AutoIndexConfig) {
var buf bytes.Buffer
// 确保路径以 / 结尾
if !strings.HasSuffix(reqPath, "/") {
reqPath += "/"
}
// HTML 头部
buf.WriteString("<!DOCTYPE html>\n")
buf.WriteString("<html>\n<head>\n")
fmt.Fprintf(&buf, "<title>Index of %s</title>\n", html.EscapeString(reqPath))
buf.WriteString("<style>\n")
buf.WriteString("body { font-family: monospace; margin: 20px; }\n")
buf.WriteString("h1 { border-bottom: 1px solid #ccc; padding-bottom: 10px; }\n")
buf.WriteString("table { border-collapse: collapse; width: 100%; }\n")
buf.WriteString("td, th { padding: 5px 10px; text-align: left; }\n")
buf.WriteString("td.size { text-align: right; }\n")
buf.WriteString("a { text-decoration: none; }\n")
buf.WriteString("a:hover { text-decoration: underline; }\n")
buf.WriteString("</style>\n")
buf.WriteString("</head>\n<body>\n")
fmt.Fprintf(&buf, "<h1>Index of %s</h1>\n", html.EscapeString(reqPath))
buf.WriteString("<hr>\n<table>\n")
buf.WriteString("<thead><tr><th>Name</th><th>Modified</th><th>Size</th></tr></thead>\n")
buf.WriteString("<tbody>\n")
// 父目录链接
if reqPath != "/" {
buf.WriteString("<tr><td><a href=\"../\">../</a></td><td>-</td><td>-</td></tr>\n")
}
// 目录条目
for _, entry := range entries {
name := entry.Name
displayName := name
href := url.PathEscape(name)
if entry.IsDir {
displayName += "/"
href += "/"
}
// 时间格式
var timeStr string
if config.Localtime {
timeStr = entry.ModTime.Local().Format("02-Jan-2006 15:04")
} else {
timeStr = entry.ModTime.UTC().Format("02-Jan-2006 15:04")
}
// 大小格式
var sizeStr string
if entry.IsDir {
sizeStr = "-"
} else if config.ExactSize {
sizeStr = fmt.Sprintf("%d", entry.Size)
} else {
sizeStr = formatSize(entry.Size)
}
fmt.Fprintf(&buf, "<tr><td><a href=\"%s\">%s</a></td><td>%s</td><td class=\"size\">%s</td></tr>\n",
href, html.EscapeString(displayName), timeStr, sizeStr)
}
buf.WriteString("</tbody>\n</table>\n<hr>\n</body>\n</html>\n")
ctx.Response.Header.Set("Content-Security-Policy", "default-src 'self'")
ctx.Response.Header.SetContentType("text/html; charset=utf-8")
ctx.Response.SetBody(buf.Bytes())
}
// generateJSONIndex 生成 JSON 格式的目录列表。
func generateJSONIndex(ctx *fasthttp.RequestCtx, _ string, entries []dirEntry) {
type jsonEntry struct {
Name string `json:"name"`
Type string `json:"type"`
Mtime string `json:"mtime"`
Size int64 `json:"size,omitempty"`
}
jsonEntries := make([]jsonEntry, 0, len(entries))
for _, entry := range entries {
e := jsonEntry{
Name: entry.Name,
Mtime: entry.ModTime.UTC().Format(time.RFC1123),
}
if entry.IsDir {
e.Type = "directory"
} else {
e.Type = "file"
e.Size = entry.Size
}
jsonEntries = append(jsonEntries, e)
}
data, err := json.MarshalIndent(jsonEntries, "", " ")
if err != nil {
ctx.Response.SetStatusCode(fasthttp.StatusInternalServerError)
return
}
ctx.Response.Header.SetContentType("application/json")
ctx.Response.SetBody(data)
}
// generateXMLIndex 生成 XML 格式的目录列表。
func generateXMLIndex(ctx *fasthttp.RequestCtx, reqPath string, entries []dirEntry) {
type xmlEntry struct {
XMLName xml.Name `xml:"element"`
Name string `xml:"name,attr"`
Type string `xml:"type,attr"`
Mtime string `xml:"mtime,attr"`
Size int64 `xml:"size,attr,omitempty"`
}
type xmlList struct {
XMLName xml.Name `xml:"list"`
Path string `xml:"path,attr"`
Elements []xmlEntry `xml:",any"`
}
xmlEntries := make([]xmlEntry, 0, len(entries))
for _, entry := range entries {
e := xmlEntry{
Name: entry.Name,
Mtime: entry.ModTime.UTC().Format(time.RFC3339),
}
if entry.IsDir {
e.Type = "directory"
} else {
e.Type = "file"
e.Size = entry.Size
}
xmlEntries = append(xmlEntries, e)
}
list := xmlList{
Path: reqPath,
Elements: xmlEntries,
}
data, err := xml.MarshalIndent(list, "", " ")
if err != nil {
ctx.Response.SetStatusCode(fasthttp.StatusInternalServerError)
return
}
ctx.Response.Header.SetContentType("text/xml; charset=utf-8")
ctx.Response.SetBody([]byte(xml.Header + string(data)))
}
// formatSize 格式化文件大小为人类可读格式。
func formatSize(size int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case size >= GB:
return fmt.Sprintf("%.1fG", float64(size)/float64(GB))
case size >= MB:
return fmt.Sprintf("%.1fM", float64(size)/float64(MB))
case size >= KB:
return fmt.Sprintf("%.1fK", float64(size)/float64(KB))
default:
return fmt.Sprintf("%d", size)
}
}