diff --git a/internal/cache/cache_bench_test.go b/internal/cache/cache_bench_test.go
index f0956de..60f1ad0 100644
--- a/internal/cache/cache_bench_test.go
+++ b/internal/cache/cache_bench_test.go
@@ -291,4 +291,3 @@ func BenchmarkProxyCacheConcurrent(b *testing.B) {
}
})
}
-
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 5116e8f..694b28a 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -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")
diff --git a/internal/config/server_config.go b/internal/config/server_config.go
index ba1070b..88e41b1 100644
--- a/internal/config/server_config.go
+++ b/internal/config/server_config.go
@@ -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 类型配置
diff --git a/internal/config/validate.go b/internal/config/validate.go
index a0c1693..11bef3e 100644
--- a/internal/config/validate.go
+++ b/internal/config/validate.go
@@ -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
}
diff --git a/internal/handler/autoindex.go b/internal/handler/autoindex.go
new file mode 100644
index 0000000..dec81b4
--- /dev/null
+++ b/internal/handler/autoindex.go
@@ -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("\n")
+ buf.WriteString("\n
\n")
+ buf.WriteString(fmt.Sprintf("Index of %s\n", escapeHTML(reqPath)))
+ buf.WriteString("\n")
+ buf.WriteString("\n\n")
+ buf.WriteString(fmt.Sprintf("Index of %s
\n", escapeHTML(reqPath)))
+ buf.WriteString("
\n\n")
+ buf.WriteString("| Name | Modified | Size |
\n")
+ buf.WriteString("\n")
+
+ // 父目录链接
+ if reqPath != "/" {
+ buf.WriteString("| ../ | - | - |
\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("| %s | %s | %s |
\n",
+ href, escapeHTML(displayName), timeStr, sizeStr))
+ }
+
+ buf.WriteString("\n
\n
\n\n\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()
+}
diff --git a/internal/handler/autoindex_test.go b/internal/handler/autoindex_test.go
new file mode 100644
index 0000000..9c47eae
--- /dev/null
+++ b/internal/handler/autoindex_test.go
@@ -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("content2"), 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(""), 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, ` 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"},
+ {"