refactor(handler): improve autoindex code quality

- Replace custom escape functions with stdlib html.EscapeString and url.PathEscape
- Fix benchmark test file naming using fmt.Sprintf
- Add CSP security header for HTML output
- Add empty directory test case
- Remove obsolete escape function tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-30 15:21:10 +08:00
parent b62a3f12da
commit 3b608be0de
2 changed files with 26 additions and 63 deletions

View File

@ -11,6 +11,8 @@ import (
"encoding/json"
"encoding/xml"
"fmt"
"html"
"net/url"
"os"
"sort"
"strings"
@ -115,7 +117,7 @@ func generateHTMLIndex(ctx *fasthttp.RequestCtx, reqPath string, entries []dirEn
// 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(fmt.Sprintf("<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")
@ -126,7 +128,7 @@ func generateHTMLIndex(ctx *fasthttp.RequestCtx, reqPath string, entries []dirEn
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(fmt.Sprintf("<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")
@ -140,7 +142,7 @@ func generateHTMLIndex(ctx *fasthttp.RequestCtx, reqPath string, entries []dirEn
for _, entry := range entries {
name := entry.Name
displayName := name
href := escapeURL(name)
href := url.PathEscape(name)
if entry.IsDir {
displayName += "/"
@ -166,11 +168,12 @@ func generateHTMLIndex(ctx *fasthttp.RequestCtx, reqPath string, entries []dirEn
}
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))
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())
}
@ -275,27 +278,3 @@ func formatSize(size int64) string {
}
}
// escapeHTML 转义 HTML 特殊字符。
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
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()
}

View File

@ -2,6 +2,7 @@
package handler
import (
"fmt"
"os"
"path/filepath"
"testing"
@ -233,42 +234,25 @@ func TestFormatSize(t *testing.T) {
}
}
func TestEscapeHTML(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"normal", "normal"},
{"<script>", "&lt;script&gt;"},
{`"quoted"`, "&quot;quoted&quot;"},
{"a&b", "a&amp;b"},
{"'single'", "&#39;single&#39;"},
// TestGenerateAutoIndex_EmptyDirectory 测试空目录
func TestGenerateAutoIndex_EmptyDirectory(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "autoindex_empty_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
ctx := &fasthttp.RequestCtx{}
config := AutoIndexConfig{Format: "json"}
if !GenerateAutoIndex(ctx, tmpDir, "/", config) {
t.Fatal("GenerateAutoIndex returned false for empty directory")
}
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)
}
body := string(ctx.Response.Body())
// JSON 格式空数组
if !containsAll(body, "[]") {
t.Errorf("Empty directory JSON = %s, should contain []", body)
}
}
@ -421,7 +405,7 @@ func BenchmarkGenerateAutoIndex_HTML(b *testing.B) {
// 创建 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 {
if err := os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i)), []byte("content"), 0o644); err != nil {
b.Fatal(err)
}
}