feat(handler): add autoindex module for directory listing
Add nginx-like autoindex functionality with three output formats: - HTML: styled directory listing with sortable columns - JSON: structured API-friendly output - XML: machine-readable format Configuration options: - auto_index: enable/disable directory listing - auto_index_format: output format (html/json/xml) - auto_index_localtime: use local time instead of GMT - auto_index_exact_size: show exact bytes vs human-readable Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
e32e96ee81
commit
b62a3f12da
1
internal/cache/cache_bench_test.go
vendored
1
internal/cache/cache_bench_test.go
vendored
@ -291,4 +291,3 @@ func BenchmarkProxyCacheConcurrent(b *testing.B) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -361,6 +361,10 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) {
|
||||
buf.WriteString(" # epoch → Cache-Control: no-cache\n")
|
||||
buf.WriteString(" # location_type: \"\" # 位置匹配类型(有效值: exact, prefix, regex, regex_caseless, prefix_priority, named)\n")
|
||||
buf.WriteString(" # internal: false # 仅允许内部重定向访问\n")
|
||||
buf.WriteString(" # auto_index: false # 启用目录列表(当目录无索引文件时)\n")
|
||||
buf.WriteString(" # auto_index_format: html # 输出格式(html/json/xml)\n")
|
||||
buf.WriteString(" # auto_index_localtime: false # 使用本地时间(默认 GMT)\n")
|
||||
buf.WriteString(" # auto_index_exact_size: false # 显示精确大小(默认人类可读 K/M/G)\n")
|
||||
}
|
||||
buf.WriteString(" # 示例:静态资源缓存配置\n")
|
||||
buf.WriteString(" # - path: \"/assets/\"\n")
|
||||
|
||||
@ -147,6 +147,23 @@ type StaticConfig struct {
|
||||
// 设置 Cache-Control: max-age 和 Expires 响应头
|
||||
// 示例:expires: 30d → Cache-Control: max-age=2592000
|
||||
Expires string `yaml:"expires"`
|
||||
|
||||
// AutoIndex 是否启用目录列表
|
||||
// 当请求目录且没有索引文件时,生成目录列表页面
|
||||
// 默认为 false,返回 403 Forbidden
|
||||
AutoIndex bool `yaml:"auto_index"`
|
||||
|
||||
// AutoIndexFormat 目录列表输出格式
|
||||
// 可选值:html(默认)、json、xml
|
||||
AutoIndexFormat string `yaml:"auto_index_format"`
|
||||
|
||||
// AutoIndexLocaltime 是否使用本地时间
|
||||
// 默认为 false,使用 GMT 时间
|
||||
AutoIndexLocaltime bool `yaml:"auto_index_localtime"`
|
||||
|
||||
// AutoIndexExactSize 是否显示精确文件大小
|
||||
// 默认为 false,显示人类可读格式(K/M/G)
|
||||
AutoIndexExactSize bool `yaml:"auto_index_exact_size"`
|
||||
}
|
||||
|
||||
// TypesConfig MIME 类型配置
|
||||
|
||||
@ -280,6 +280,14 @@ func validateStatics(statics []StaticConfig) error {
|
||||
return fmt.Errorf("static[%d].try_files[%d]: %w", i, j, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 auto_index_format
|
||||
if s.AutoIndex {
|
||||
validFormats := []string{"", "html", "json", "xml"}
|
||||
if err := ValidateEnum(s.AutoIndexFormat, validFormats, "auto_index_format"); err != nil {
|
||||
return fmt.Errorf("static[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
301
internal/handler/autoindex.go
Normal file
301
internal/handler/autoindex.go
Normal file
@ -0,0 +1,301 @@
|
||||
// Package handler 提供 HTTP 请求处理功能。
|
||||
//
|
||||
// 该文件实现目录列表(autoindex)功能,类似 nginx 的 autoindex 模块。
|
||||
// 支持三种输出格式:HTML、JSON、XML。
|
||||
//
|
||||
// 作者:xfy
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"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 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")
|
||||
buf.WriteString(fmt.Sprintf("<title>Index of %s</title>\n", escapeHTML(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")
|
||||
buf.WriteString(fmt.Sprintf("<h1>Index of %s</h1>\n", escapeHTML(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 := escapeURL(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)
|
||||
}
|
||||
|
||||
buf.WriteString(fmt.Sprintf("<tr><td><a href=\"%s\">%s</a></td><td>%s</td><td class=\"size\">%s</td></tr>\n",
|
||||
href, escapeHTML(displayName), timeStr, sizeStr))
|
||||
}
|
||||
|
||||
buf.WriteString("</tbody>\n</table>\n<hr>\n</body>\n</html>\n")
|
||||
|
||||
ctx.Response.Header.SetContentType("text/html; charset=utf-8")
|
||||
ctx.Response.SetBody(buf.Bytes())
|
||||
}
|
||||
|
||||
// generateJSONIndex 生成 JSON 格式的目录列表。
|
||||
func generateJSONIndex(ctx *fasthttp.RequestCtx, reqPath 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)
|
||||
}
|
||||
}
|
||||
|
||||
// escapeHTML 转义 HTML 特殊字符。
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
// escapeURL 转义 URL 特殊字符。
|
||||
func escapeURL(s string) string {
|
||||
var buf bytes.Buffer
|
||||
for _, c := range s {
|
||||
// 保留安全字符
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.' || c == '~' || c == '/' {
|
||||
buf.WriteRune(c)
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf("%%%02X", c))
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
439
internal/handler/autoindex_test.go
Normal file
439
internal/handler/autoindex_test.go
Normal file
@ -0,0 +1,439 @@
|
||||
// Package handler 提供 HTTP 请求处理功能。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func TestGenerateAutoIndex_HTML(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建测试文件和目录
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "file2.html"), []byte("<html>content2</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, ".hidden"), []byte("hidden"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 测试 HTML 格式
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
ctx.Request.SetRequestURI("/test/")
|
||||
|
||||
config := AutoIndexConfig{
|
||||
Format: "html",
|
||||
Localtime: false,
|
||||
ExactSize: false,
|
||||
}
|
||||
|
||||
if !GenerateAutoIndex(ctx, tmpDir, "/test/", config) {
|
||||
t.Fatal("GenerateAutoIndex returned false")
|
||||
}
|
||||
|
||||
if ct := string(ctx.Response.Header.ContentType()); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %s, want text/html; charset=utf-8", ct)
|
||||
}
|
||||
|
||||
body := string(ctx.Response.Body())
|
||||
// 检查包含文件名
|
||||
if !containsAll(body, "file1.txt", "file2.html", "subdir") {
|
||||
t.Errorf("HTML body missing expected files: %s", body)
|
||||
}
|
||||
// 检查隐藏文件不显示
|
||||
if containsAll(body, ".hidden") {
|
||||
t.Errorf("HTML body should not contain hidden file: %s", body)
|
||||
}
|
||||
// 检查目录有斜杠后缀
|
||||
if !containsAll(body, "subdir/") {
|
||||
t.Errorf("HTML body directory should have / suffix: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAutoIndex_JSON(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建测试文件
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "test.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 测试 JSON 格式
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
ctx.Request.SetRequestURI("/api/")
|
||||
|
||||
config := AutoIndexConfig{
|
||||
Format: "json",
|
||||
}
|
||||
|
||||
if !GenerateAutoIndex(ctx, tmpDir, "/api/", config) {
|
||||
t.Fatal("GenerateAutoIndex returned false")
|
||||
}
|
||||
|
||||
if ct := string(ctx.Response.Header.ContentType()); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %s, want application/json", ct)
|
||||
}
|
||||
|
||||
body := string(ctx.Response.Body())
|
||||
// 检查 JSON 格式
|
||||
if !containsAll(body, `"name"`, `"type"`, `"mtime"`, "test.json") {
|
||||
t.Errorf("JSON body missing expected fields: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAutoIndex_XML(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建测试文件
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "data.xml"), []byte("<data/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 测试 XML 格式
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
ctx.Request.SetRequestURI("/xml/")
|
||||
|
||||
config := AutoIndexConfig{
|
||||
Format: "xml",
|
||||
}
|
||||
|
||||
if !GenerateAutoIndex(ctx, tmpDir, "/xml/", config) {
|
||||
t.Fatal("GenerateAutoIndex returned false")
|
||||
}
|
||||
|
||||
if ct := string(ctx.Response.Header.ContentType()); ct != "text/xml; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %s, want text/xml; charset=utf-8", ct)
|
||||
}
|
||||
|
||||
body := string(ctx.Response.Body())
|
||||
// 检查 XML 格式
|
||||
if !containsAll(body, `<list`, `path=`, `<element`, "data.xml") {
|
||||
t.Errorf("XML body missing expected elements: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAutoIndex_Sorting(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建文件和目录(按不同顺序)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "z_file.txt"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(tmpDir, "a_dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "m_file.txt"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 测试排序
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
config := AutoIndexConfig{Format: "json"}
|
||||
|
||||
if !GenerateAutoIndex(ctx, tmpDir, "/", config) {
|
||||
t.Fatal("GenerateAutoIndex returned false")
|
||||
}
|
||||
|
||||
body := string(ctx.Response.Body())
|
||||
// 目录应该排在前面
|
||||
dirIdx := indexOf(body, `"a_dir"`)
|
||||
zFileIdx := indexOf(body, `"z_file.txt"`)
|
||||
mFileIdx := indexOf(body, `"m_file.txt"`)
|
||||
|
||||
if dirIdx == -1 || zFileIdx == -1 || mFileIdx == -1 {
|
||||
t.Fatalf("Missing entries in body: %s", body)
|
||||
}
|
||||
|
||||
if dirIdx > zFileIdx || dirIdx > mFileIdx {
|
||||
t.Errorf("Directories should come first: dirIdx=%d, zFileIdx=%d, mFileIdx=%d", dirIdx, zFileIdx, mFileIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAutoIndex_SizeFormatting(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建不同大小的文件
|
||||
smallFile := filepath.Join(tmpDir, "small.txt")
|
||||
if err := os.WriteFile(smallFile, make([]byte, 100), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 测试人类可读格式
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
config := AutoIndexConfig{
|
||||
Format: "html",
|
||||
ExactSize: false,
|
||||
}
|
||||
|
||||
if !GenerateAutoIndex(ctx, tmpDir, "/", config) {
|
||||
t.Fatal("GenerateAutoIndex returned false")
|
||||
}
|
||||
|
||||
body := string(ctx.Response.Body())
|
||||
if !containsAll(body, "small.txt") {
|
||||
t.Errorf("Missing file in output: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
size int64
|
||||
expected string
|
||||
}{
|
||||
{0, "0"},
|
||||
{100, "100"},
|
||||
{1024, "1.0K"},
|
||||
{1536, "1.5K"},
|
||||
{1048576, "1.0M"},
|
||||
{1572864, "1.5M"},
|
||||
{1073741824, "1.0G"},
|
||||
{1610612736, "1.5G"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatSize(tt.size)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatSize(%d) = %s, want %s", tt.size, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeHTML(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"normal", "normal"},
|
||||
{"<script>", "<script>"},
|
||||
{`"quoted"`, ""quoted""},
|
||||
{"a&b", "a&b"},
|
||||
{"'single'", "'single'"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := escapeHTML(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("escapeHTML(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"normal", "normal"},
|
||||
{"file name.txt", "file%20name.txt"},
|
||||
{"file?name.txt", "file%3Fname.txt"},
|
||||
{"safe-file_123.txt", "safe-file_123.txt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := escapeURL(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("escapeURL(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func containsAll(s string, substrs ...string) bool {
|
||||
for _, substr := range substrs {
|
||||
if !contains(s, substr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
|
||||
}
|
||||
|
||||
func containsHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// 确保 autoindex 在 StaticHandler 中工作
|
||||
func TestStaticHandler_AutoIndex(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_static_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建测试文件
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 创建处理器并启用 autoindex
|
||||
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
|
||||
handler.SetAutoIndex(true, "html", false, false)
|
||||
|
||||
// 测试目录请求
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
ctx.Request.SetRequestURI("/")
|
||||
handler.Handle(ctx)
|
||||
|
||||
if ctx.Response.StatusCode() != 200 {
|
||||
t.Errorf("Status = %d, want 200", ctx.Response.StatusCode())
|
||||
}
|
||||
|
||||
body := string(ctx.Response.Body())
|
||||
if !containsAll(body, "test.txt") {
|
||||
t.Errorf("AutoIndex response missing file: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 autoindex 关闭时返回 403
|
||||
func TestStaticHandler_AutoIndex_Disabled(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_disabled_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建处理器(不启用 autoindex)
|
||||
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
|
||||
|
||||
// 测试目录请求
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
ctx.Request.SetRequestURI("/")
|
||||
handler.Handle(ctx)
|
||||
|
||||
if ctx.Response.StatusCode() != 403 {
|
||||
t.Errorf("Status = %d, want 403", ctx.Response.StatusCode())
|
||||
}
|
||||
}
|
||||
|
||||
// 测试时间格式
|
||||
func TestGenerateAutoIndex_TimeFormat(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_time_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建文件
|
||||
testFile := filepath.Join(tmpDir, "time.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 测试 GMT 时间
|
||||
ctx1 := &fasthttp.RequestCtx{}
|
||||
config1 := AutoIndexConfig{Format: "html", Localtime: false}
|
||||
GenerateAutoIndex(ctx1, tmpDir, "/", config1)
|
||||
|
||||
// 测试本地时间
|
||||
ctx2 := &fasthttp.RequestCtx{}
|
||||
config2 := AutoIndexConfig{Format: "html", Localtime: true}
|
||||
GenerateAutoIndex(ctx2, tmpDir, "/", config2)
|
||||
|
||||
// 两个响应应该都成功
|
||||
if ctx1.Response.StatusCode() != 200 || ctx2.Response.StatusCode() != 200 {
|
||||
t.Errorf("Expected status 200 for both time formats")
|
||||
}
|
||||
|
||||
// 验证时间格式存在(格式:02-Jan-2006 15:04)
|
||||
body1 := string(ctx1.Response.Body())
|
||||
if len(body1) < 10 {
|
||||
t.Errorf("HTML body too short")
|
||||
}
|
||||
}
|
||||
|
||||
// 确保编译时检查接口
|
||||
func TestAutoIndexConfig_CompileTimeCheck(t *testing.T) {
|
||||
// 确保 AutoIndexConfig 结构体字段正确
|
||||
config := AutoIndexConfig{
|
||||
Format: "html",
|
||||
Localtime: true,
|
||||
ExactSize: true,
|
||||
}
|
||||
|
||||
if config.Format != "html" {
|
||||
t.Errorf("Format = %s, want html", config.Format)
|
||||
}
|
||||
}
|
||||
|
||||
// 基准测试
|
||||
func BenchmarkGenerateAutoIndex_HTML(b *testing.B) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "autoindex_bench")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 创建 100 个文件
|
||||
for i := 0; i < 100; i++ {
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "file"+string(rune('0'+i%10))+string(rune('0'+i/10))+".txt"), []byte("content"), 0o644); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
config := AutoIndexConfig{Format: "html"}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
GenerateAutoIndex(ctx, tmpDir, "/", config)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保时间包导入
|
||||
var _ = time.Second
|
||||
@ -11,7 +11,7 @@ func TestFileInfoCache(t *testing.T) {
|
||||
// 创建临时文件
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(tmpFile, []byte("hello"), 0644); err != nil {
|
||||
if err := os.WriteFile(tmpFile, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -78,7 +78,7 @@ func TestFileInfoCacheTTL(t *testing.T) {
|
||||
// 创建临时文件
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(tmpFile, []byte("hello"), 0644); err != nil {
|
||||
if err := os.WriteFile(tmpFile, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ func TestFileInfoCacheLRU(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
for i := range fileInfoCacheMaxEntries + 10 {
|
||||
tmpFile := filepath.Join(tmpDir, "test"+string(rune('0'+i%10))+".txt")
|
||||
os.WriteFile(tmpFile, []byte("hello"), 0644)
|
||||
os.WriteFile(tmpFile, []byte("hello"), 0o644)
|
||||
info, _ := os.Stat(tmpFile)
|
||||
cache.Set(tmpFile, info)
|
||||
}
|
||||
|
||||
@ -64,9 +64,14 @@ type StaticHandler struct {
|
||||
// 切片字段
|
||||
index []string
|
||||
tryFiles []string
|
||||
// AutoIndex 配置
|
||||
autoIndex bool
|
||||
autoIndexFormat string
|
||||
autoIndexLocaltime bool
|
||||
autoIndexExactSize bool
|
||||
// 基本类型字段
|
||||
pathPrefixLen int // 预计算的路径前缀长度,用于零分配路径剥离
|
||||
cacheTTL time.Duration // 缓存新鲜度 TTL(默认 5s,0 表示每次验证 ModTime)
|
||||
pathPrefixLen int // 预计算的路径前缀长度,用于零分配路径剥离
|
||||
cacheTTL time.Duration // 缓存新鲜度 TTL(默认 5s,0 表示每次验证 ModTime)
|
||||
useSendfile bool
|
||||
tryFilesPass bool
|
||||
symlinkCheck bool
|
||||
@ -282,6 +287,22 @@ func (h *StaticHandler) SetExpires(expires string) {
|
||||
h.expires = expires
|
||||
}
|
||||
|
||||
// SetAutoIndex 设置目录列表功能。
|
||||
//
|
||||
// 启用后,当请求目录且没有索引文件时,生成目录列表页面。
|
||||
//
|
||||
// 参数:
|
||||
// - enabled: 是否启用
|
||||
// - format: 输出格式(html/json/xml)
|
||||
// - localtime: 使用本地时间
|
||||
// - exactSize: 显示精确大小
|
||||
func (h *StaticHandler) SetAutoIndex(enabled bool, format string, localtime, exactSize bool) {
|
||||
h.autoIndex = enabled
|
||||
h.autoIndexFormat = format
|
||||
h.autoIndexLocaltime = localtime
|
||||
h.autoIndexExactSize = exactSize
|
||||
}
|
||||
|
||||
// SetCacheTTL 设置缓存新鲜度 TTL。
|
||||
//
|
||||
// TTL 控制缓存条目的新鲜度验证间隔。
|
||||
@ -560,12 +581,22 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 尝试 autoindex
|
||||
if h.autoIndex {
|
||||
config := AutoIndexConfig{
|
||||
Format: h.autoIndexFormat,
|
||||
Localtime: h.autoIndexLocaltime,
|
||||
ExactSize: h.autoIndexExactSize,
|
||||
}
|
||||
if GenerateAutoIndex(ctx, filePath, reqPath, config) {
|
||||
return
|
||||
}
|
||||
}
|
||||
utils.SendError(ctx, utils.ErrForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: 缓存查找 + TTL 验证
|
||||
// 在 serveFile 调用前检查缓存,减少 os.ReadFile 调用
|
||||
// Phase 2: 缓存查找 + TTL 验证 // 在 serveFile 调用前检查缓存,减少 os.ReadFile 调用
|
||||
// 注意: CachedAt 迁移已在 FileCache.Get() 内部完成,确保并发安全
|
||||
etag := generateETag(info.ModTime(), info.Size())
|
||||
if isNotModified(ctx, etag, info.ModTime()) {
|
||||
|
||||
@ -138,6 +138,16 @@ func (s *Server) registerStaticHandlersWithLocationEngine(cfg *config.ServerConf
|
||||
staticHandler.SetExpires(static.Expires)
|
||||
}
|
||||
|
||||
// 设置目录列表
|
||||
if static.AutoIndex {
|
||||
staticHandler.SetAutoIndex(
|
||||
static.AutoIndex,
|
||||
static.AutoIndexFormat,
|
||||
static.AutoIndexLocaltime,
|
||||
static.AutoIndexExactSize,
|
||||
)
|
||||
}
|
||||
|
||||
// 根据 LocationType 注册路由
|
||||
locType := static.LocationType
|
||||
if locType == "" {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user