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("\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("\n", + href, escapeHTML(displayName), timeStr, sizeStr)) + } + + buf.WriteString("\n
NameModifiedSize
../--
%s%s%s
\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"}, + {"