feat(handler,config): 支持 try_files 动态后缀 ($uri.<ext>)
- 实现 $uri.<ext> 模式解析(如 $uri.html, $uri.json) - 添加扩展名安全验证:白名单字符、危险后缀黑名单 - 根路径边界处理:避免生成 "/.html" 隐藏文件名 - 增强配置文档注释,说明 nginx 兼容性和安全限制 - 完整单元测试覆盖 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ada7357f7d
commit
76c2a6028c
@ -317,8 +317,30 @@ type StaticConfig struct {
|
||||
Index []string `yaml:"index"`
|
||||
|
||||
// TryFiles 按顺序尝试查找的文件列表
|
||||
// 支持 $uri 和 $uri/ 占位符,用于 SPA 部署
|
||||
// 示例: ["$uri", "$uri/", "/index.html"]
|
||||
// 支持以下模式:
|
||||
// - $uri: 请求路径
|
||||
// - $uri/: 请求路径加斜杠(目录)
|
||||
// - $uri.<ext>: 请求路径加扩展名(如 $uri.html, $uri.json)
|
||||
// - /path: 绝对路径回退(如 /index.html)
|
||||
// - filename: 相对路径回退(如 fallback.html)
|
||||
//
|
||||
// nginx 兼容性:
|
||||
// - $uri 变量语义与 nginx try_files 指令一致
|
||||
// - 配置语法可从 nginx 直接迁移
|
||||
//
|
||||
// 安全限制(附加于 nginx 基础):
|
||||
// - 扩展名仅允许字母、数字、点、下划线、连字符
|
||||
// - 禁止危险后缀(.php, .exe, .bat 等)
|
||||
// - 禁止 null byte 和路径分隔符
|
||||
//
|
||||
// 根路径边界情况:
|
||||
// - 当 relPath="/" 且模式为 "$uri.<ext>" 时,返回空字符串
|
||||
// - 此设计避免生成 "/.html" 这样的隐藏文件名
|
||||
// - 建议使用绝对路径回退(如 /index.html)处理根路径
|
||||
//
|
||||
// 示例:
|
||||
// try_files: ["$uri", "$uri.html", "/index.html"]
|
||||
// try_files: ["$uri", "$uri/", "/app.html"]
|
||||
TryFiles []string `yaml:"try_files"`
|
||||
|
||||
// TryFilesPass 内部重定向是否触发中间件
|
||||
|
||||
@ -129,10 +129,167 @@ func validateStatics(statics []StaticConfig) error {
|
||||
if s.Root != "" && strings.Contains(s.Root, "..") {
|
||||
return fmt.Errorf("static[%d]: 根目录路径不能包含 '..'", i)
|
||||
}
|
||||
|
||||
// 验证 try_files 模式
|
||||
for j, pattern := range s.TryFiles {
|
||||
if err := validateTryFilesPattern(pattern); err != nil {
|
||||
return fmt.Errorf("static[%d].try_files[%d]: %w", i, j, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTryFilesPattern 验证 try_files 模式的安全性。
|
||||
//
|
||||
// 检查 try_files 配置项是否包含安全风险的模式。
|
||||
// 支持的模式格式:
|
||||
// - $uri - 请求路径
|
||||
// - $uri/ - 请求路径加斜杠
|
||||
// - $uri.<ext> - 请求路径加扩展名(如 $uri.html)
|
||||
// - /path - 绝对路径回退
|
||||
// - filename - 相对路径回退
|
||||
//
|
||||
// 验证规则:
|
||||
// - 拒绝 null byte(\x00)
|
||||
// - 拒绝路径分隔符(/ 和 \)在扩展名中
|
||||
// - 扩展名仅允许字母、数字、点、下划线、连字符
|
||||
// - 拒绝危险后缀(.php, .exe, .bat, .sh, .cgi 等)
|
||||
//
|
||||
// 参数:
|
||||
// - pattern: try_files 配置项
|
||||
//
|
||||
// 返回值:
|
||||
// - error: 验证失败时返回具体错误信息
|
||||
func validateTryFilesPattern(pattern string) error {
|
||||
if pattern == "" {
|
||||
return errors.New("try_files 模式不能为空")
|
||||
}
|
||||
|
||||
// 检查 null byte
|
||||
if strings.Contains(pattern, "\x00") {
|
||||
return errors.New("try_files 模式不能包含 null byte")
|
||||
}
|
||||
|
||||
// 定义支持的模式类型
|
||||
// 1. $uri 占位符
|
||||
if pattern == "$uri" || pattern == "$uri/" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. $uri.<ext> 动态后缀
|
||||
if strings.HasPrefix(pattern, "$uri.") {
|
||||
ext := pattern[5:] // 提取扩展名部分
|
||||
|
||||
// 检查扩展名安全性
|
||||
if err := validateTryFilesExtension(ext); err != nil {
|
||||
return fmt.Errorf("try_files 模式 %q: %w", pattern, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 绝对路径回退(以 / 开头)
|
||||
if strings.HasPrefix(pattern, "/") {
|
||||
// 验证路径不包含危险字符
|
||||
if strings.Contains(pattern, "..") {
|
||||
return fmt.Errorf("try_files 模式 %q 不能包含路径遍历", pattern)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 相对路径回退(文件名)
|
||||
// 检查是否为安全文件名
|
||||
if err := validateTryFilesFilename(pattern); err != nil {
|
||||
return fmt.Errorf("try_files 模式 %q: %w", pattern, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTryFilesExtension 验证动态后缀扩展名的安全性。
|
||||
//
|
||||
// 检查扩展名是否包含危险字符或属于危险后缀列表。
|
||||
//
|
||||
// 参数:
|
||||
// - ext: 扩展名字符串(不包含前导点)
|
||||
//
|
||||
// 返回值:
|
||||
// - error: 验证失败时返回错误信息
|
||||
func validateTryFilesExtension(ext string) error {
|
||||
if ext == "" {
|
||||
return errors.New("扩展名不能为空")
|
||||
}
|
||||
|
||||
// 检查路径分隔符
|
||||
if strings.ContainsAny(ext, "/\\") {
|
||||
return errors.New("扩展名不能包含路径分隔符")
|
||||
}
|
||||
|
||||
// 检查 null byte
|
||||
if strings.Contains(ext, "\x00") {
|
||||
return errors.New("扩展名不能包含 null byte")
|
||||
}
|
||||
|
||||
// 白名单字符检查:仅允许字母、数字、点、下划线、连字符
|
||||
for i, c := range ext {
|
||||
isLetter := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
isDigit := c >= '0' && c <= '9'
|
||||
isAllowed := c == '.' || c == '_' || c == '-'
|
||||
if !isLetter && !isDigit && !isAllowed {
|
||||
return fmt.Errorf("扩展名包含非法字符 %q (位置 %d)", c, i)
|
||||
}
|
||||
}
|
||||
|
||||
// 危险后缀黑名单(不含前导点,与 ext 格式一致)
|
||||
dangerousExtensions := []string{
|
||||
"php", "php3", "php4", "php5", "phtml",
|
||||
"exe", "bat", "cmd", "sh", "bash",
|
||||
"cgi", "pl", "py", "rb",
|
||||
"asp", "aspx", "jsp",
|
||||
}
|
||||
|
||||
extLower := strings.ToLower(ext)
|
||||
for _, dangerous := range dangerousExtensions {
|
||||
if extLower == dangerous || strings.HasSuffix(extLower, "."+dangerous) {
|
||||
return fmt.Errorf("扩展名 %q 被禁止(潜在安全风险)", ext)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTryFilesFilename 验证回退文件名的安全性。
|
||||
//
|
||||
// 检查文件名是否包含路径遍历或危险字符。
|
||||
//
|
||||
// 参数:
|
||||
// - filename: 文件名字符串
|
||||
//
|
||||
// 返回值:
|
||||
// - error: 验证失败时返回错误信息
|
||||
func validateTryFilesFilename(filename string) error {
|
||||
if filename == "" {
|
||||
return errors.New("文件名不能为空")
|
||||
}
|
||||
|
||||
// 检查路径遍历
|
||||
if strings.Contains(filename, "..") {
|
||||
return errors.New("文件名不能包含路径遍历")
|
||||
}
|
||||
|
||||
// 检查路径分隔符
|
||||
if strings.ContainsAny(filename, "/\\") {
|
||||
return errors.New("文件名不能包含路径分隔符")
|
||||
}
|
||||
|
||||
// 检查 null byte
|
||||
if strings.Contains(filename, "\x00") {
|
||||
return errors.New("文件名不能包含 null byte")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePathConflicts 检查 static 和 proxy 路径冲突。
|
||||
//
|
||||
// 确保 static 和 proxy 没有相同的 path 前缀。
|
||||
|
||||
@ -1222,3 +1222,175 @@ func TestValidateVariables(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTryFilesPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
// 基本占位符
|
||||
{name: "有效 $uri", pattern: "$uri", wantErr: false},
|
||||
{name: "有效 $uri/", pattern: "$uri/", wantErr: false},
|
||||
|
||||
// 动态后缀
|
||||
{name: "有效 $uri.html", pattern: "$uri.html", wantErr: false},
|
||||
{name: "有效 $uri.json", pattern: "$uri.json", wantErr: false},
|
||||
{name: "有效 $uri.css", pattern: "$uri.css", wantErr: false},
|
||||
{name: "有效 $uri.js", pattern: "$uri.js", wantErr: false},
|
||||
{name: "有效 $uri.xml", pattern: "$uri.xml", wantErr: false},
|
||||
{name: "有效 $uri.webmanifest", pattern: "$uri.webmanifest", wantErr: false},
|
||||
{name: "有效 $uri.txt", pattern: "$uri.txt", wantErr: false},
|
||||
{name: "有效 $uri.svg", pattern: "$uri.svg", wantErr: false},
|
||||
{name: "有效 $uri.woff2", pattern: "$uri.woff2", wantErr: false},
|
||||
|
||||
// 绝对路径回退
|
||||
{name: "有效绝对路径", pattern: "/index.html", wantErr: false},
|
||||
{name: "有效嵌套路径", pattern: "/fallback/index.html", wantErr: false},
|
||||
|
||||
// 相对路径回退
|
||||
{name: "有效文件名", pattern: "fallback.html", wantErr: false},
|
||||
{name: "有效嵌套文件名", pattern: "app-shell.html", wantErr: false},
|
||||
|
||||
// 安全检查 - null byte
|
||||
{name: "拒绝 null byte", pattern: "$uri\x00.html", wantErr: true, errMsg: "null byte"},
|
||||
{name: "拒绝扩展名 null byte", pattern: "$uri.ht\x00ml", wantErr: true, errMsg: "null byte"},
|
||||
|
||||
// 安全检查 - 路径分隔符
|
||||
{name: "拒绝扩展名中斜杠", pattern: "$uri./../etc/passwd", wantErr: true, errMsg: "路径分隔符"},
|
||||
{name: "拒绝扩展名中反斜杠", pattern: "$uri.\\..\\passwd", wantErr: true, errMsg: "路径分隔符"},
|
||||
{name: "拒绝扩展名中单个斜杠", pattern: "$uri.dir/file", wantErr: true, errMsg: "路径分隔符"},
|
||||
|
||||
// 安全检查 - 危险后缀
|
||||
{name: "拒绝 .php 后缀", pattern: "$uri.php", wantErr: true, errMsg: "被禁止"},
|
||||
{name: "拒绝 .exe 后缀", pattern: "$uri.exe", wantErr: true, errMsg: "被禁止"},
|
||||
{name: "拒绝 .bat 后缀", pattern: "$uri.bat", wantErr: true, errMsg: "被禁止"},
|
||||
{name: "拒绝 .sh 后缀", pattern: "$uri.sh", wantErr: true, errMsg: "被禁止"},
|
||||
{name: "拒绝 .cgi 后缀", pattern: "$uri.cgi", wantErr: true, errMsg: "被禁止"},
|
||||
{name: "拒绝 .phtml 后缀", pattern: "$uri.phtml", wantErr: true, errMsg: "被禁止"},
|
||||
{name: "拒绝 .PHP 大写后缀", pattern: "$uri.PHP", wantErr: true, errMsg: "被禁止"},
|
||||
|
||||
// 安全检查 - 非法字符
|
||||
{name: "拒绝扩展名中空格", pattern: "$uri. html", wantErr: true, errMsg: "非法字符"},
|
||||
{name: "拒绝扩展名中特殊字符", pattern: "$uri.<script>", wantErr: true, errMsg: "非法字符"},
|
||||
{name: "拒绝扩展名中百分号", pattern: "$uri.%20", wantErr: true, errMsg: "非法字符"},
|
||||
{name: "拒绝扩展名中中文", pattern: "$uri.测试", wantErr: true, errMsg: "非法字符"},
|
||||
|
||||
// 空模式
|
||||
{name: "拒绝空模式", pattern: "", wantErr: true, errMsg: "不能为空"},
|
||||
{name: "拒绝空扩展名", pattern: "$uri.", wantErr: true, errMsg: "扩展名不能为空"},
|
||||
|
||||
// 路径遍历
|
||||
{name: "拒绝绝对路径遍历", pattern: "/../../../etc/passwd", wantErr: true, errMsg: "路径遍历"},
|
||||
{name: "拒绝文件名路径遍历", pattern: "../etc/passwd", wantErr: true, errMsg: "路径遍历"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateTryFilesPattern(tt.pattern)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("validateTryFilesPattern(%q) 期望返回错误,但返回 nil", tt.pattern)
|
||||
return
|
||||
}
|
||||
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("错误消息不匹配,期望包含 %q,实际 %q", tt.errMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateTryFilesPattern(%q) 期望返回 nil,但返回错误: %v", tt.pattern, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStaticsWithTryFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statics []StaticConfig
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "有效 try_files 配置",
|
||||
statics: []StaticConfig{
|
||||
{
|
||||
Path: "/",
|
||||
Root: "/var/www",
|
||||
TryFiles: []string{"$uri", "$uri.html", "/index.html"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "多静态目录有效配置",
|
||||
statics: []StaticConfig{
|
||||
{
|
||||
Path: "/",
|
||||
Root: "/var/www",
|
||||
TryFiles: []string{"$uri", "/index.html"},
|
||||
},
|
||||
{
|
||||
Path: "/api",
|
||||
Root: "/var/api",
|
||||
TryFiles: []string{"$uri.json", "/api.json"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "无效 try_files 模式",
|
||||
statics: []StaticConfig{
|
||||
{
|
||||
Path: "/",
|
||||
Root: "/var/www",
|
||||
TryFiles: []string{"$uri", "$uri.php"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "try_files[1]",
|
||||
},
|
||||
{
|
||||
name: "空 try_files 配置",
|
||||
statics: []StaticConfig{
|
||||
{
|
||||
Path: "/",
|
||||
Root: "/var/www",
|
||||
TryFiles: []string{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "无 try_files 配置",
|
||||
statics: []StaticConfig{
|
||||
{
|
||||
Path: "/",
|
||||
Root: "/var/www",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateStatics(tt.statics)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("期望返回错误,但返回 nil")
|
||||
return
|
||||
}
|
||||
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("错误消息不匹配,期望包含 %q,实际 %q", tt.errMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("期望返回 nil,但返回错误: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -317,18 +317,41 @@ func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string)
|
||||
|
||||
// resolveTryFilePath 解析 try_files 中的占位符。
|
||||
//
|
||||
// 支持的占位符:
|
||||
// - $uri: 请求路径
|
||||
// - $uri/: 请求路径加斜杠
|
||||
// - $uri.<ext>: 请求路径加扩展名(如 $uri.html)
|
||||
//
|
||||
// nginx 兼容性说明:
|
||||
// - $uri 变量语义与 nginx try_files 一致
|
||||
// - 附加安全验证在 validateStatics 时执行
|
||||
//
|
||||
// 参数:
|
||||
// - tryFile: try_files 配置项
|
||||
// - tryFile: try_files 配置项(已在 validateStatics 时验证)
|
||||
// - relPath: 相对请求路径
|
||||
//
|
||||
// 返回值:
|
||||
// - string: 解析后的文件路径
|
||||
// - string: 解析后的文件路径,根路径边界返回空字符串触发回退
|
||||
func (h *StaticHandler) resolveTryFilePath(tryFile, relPath string) string {
|
||||
switch {
|
||||
// ====== 保留:现有逻辑 ======
|
||||
case tryFile == "$uri":
|
||||
return relPath
|
||||
case tryFile == "$uri/":
|
||||
return relPath + "/"
|
||||
|
||||
// ====== 新增:动态后缀支持 ======
|
||||
case strings.HasPrefix(tryFile, "$uri."):
|
||||
// 提取后缀部分(包含点,如 ".html")
|
||||
suffix := tryFile[4:] // "$uri" 是4个字符,后面是 ".html" 等后缀
|
||||
// 根路径边界处理:返回空字符串让 try_files 继续下一个条目
|
||||
// 避免 "/.html" 这样的隐藏文件名
|
||||
if relPath == "/" {
|
||||
return "" // 触发回退到下一个 try_files 条目
|
||||
}
|
||||
return relPath + suffix
|
||||
|
||||
// ====== 保留:现有逻辑 ======
|
||||
case strings.HasPrefix(tryFile, "/"):
|
||||
// 绝对路径,直接返回(去掉开头的 /)
|
||||
return tryFile[1:]
|
||||
|
||||
@ -1448,3 +1448,139 @@ func TestStaticHandler_LargeFileContentType(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTryFilePathWithDynamicSuffix 测试动态后缀解析
|
||||
func TestResolveTryFilePathWithDynamicSuffix(t *testing.T) {
|
||||
handler := NewStaticHandler("/var/www", "/", []string{"index.html"}, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tryFile string
|
||||
relPath string
|
||||
wantResult string
|
||||
}{
|
||||
// 基本占位符
|
||||
{name: "$uri", tryFile: "$uri", relPath: "/api/user", wantResult: "/api/user"},
|
||||
{name: "$uri/", tryFile: "$uri/", relPath: "/api/user", wantResult: "/api/user/"},
|
||||
|
||||
// 动态后缀 - 正常路径
|
||||
{name: "$uri.html 正常", tryFile: "$uri.html", relPath: "/about", wantResult: "/about.html"},
|
||||
{name: "$uri.json 正常", tryFile: "$uri.json", relPath: "/api/data", wantResult: "/api/data.json"},
|
||||
{name: "$uri.css 正常", tryFile: "$uri.css", relPath: "/styles/main", wantResult: "/styles/main.css"},
|
||||
|
||||
// 动态后缀 - 根路径边界(返回空字符串)
|
||||
{name: "$uri.html 根路径", tryFile: "$uri.html", relPath: "/", wantResult: ""},
|
||||
{name: "$uri.json 根路径", tryFile: "$uri.json", relPath: "/", wantResult: ""},
|
||||
|
||||
// 动态后缀 - 子目录路径(正常处理)
|
||||
{name: "$uri.html 子目录", tryFile: "$uri.html", relPath: "/api/", wantResult: "/api/.html"},
|
||||
{name: "$uri.json 子目录", tryFile: "$uri.json", relPath: "/v1/", wantResult: "/v1/.json"},
|
||||
|
||||
// 绝对路径
|
||||
{name: "绝对路径", tryFile: "/index.html", relPath: "/api/user", wantResult: "index.html"},
|
||||
{name: "绝对路径嵌套", tryFile: "/fallback/app.html", relPath: "/any", wantResult: "fallback/app.html"},
|
||||
|
||||
// 相对路径
|
||||
{name: "相对路径", tryFile: "fallback.html", relPath: "/api/user", wantResult: "fallback.html"},
|
||||
{name: "相对路径带连字符", tryFile: "app-shell.html", relPath: "/any", wantResult: "app-shell.html"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := handler.resolveTryFilePath(tt.tryFile, tt.relPath)
|
||||
if got != tt.wantResult {
|
||||
t.Errorf("resolveTryFilePath(%q, %q) = %q, want %q",
|
||||
tt.tryFile, tt.relPath, got, tt.wantResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticHandler_TryFilesWithDynamicSuffix 测试动态后缀集成
|
||||
func TestStaticHandler_TryFilesWithDynamicSuffix(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// 创建测试文件
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "about.html"), []byte("about page"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(tmpDir, "api"), 0755); err != nil {
|
||||
t.Fatalf("创建目录失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "api", "data.json"), []byte("{\"data\":true}"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("fallback"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
|
||||
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
|
||||
handler.SetTryFiles([]string{"$uri", "$uri.html", "/index.html"}, false, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "找到 $uri.html",
|
||||
path: "/about",
|
||||
wantStatus: fasthttp.StatusOK,
|
||||
wantContent: "about page",
|
||||
},
|
||||
{
|
||||
name: "回退到 /index.html",
|
||||
path: "/nonexistent",
|
||||
wantStatus: fasthttp.StatusOK,
|
||||
wantContent: "fallback",
|
||||
},
|
||||
{
|
||||
name: "根路径回退到 /index.html",
|
||||
path: "/",
|
||||
wantStatus: fasthttp.StatusOK,
|
||||
wantContent: "fallback",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := newTestContext(t, tt.path)
|
||||
handler.Handle(ctx)
|
||||
|
||||
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
|
||||
t.Errorf("状态码 = %d, want %d", got, tt.wantStatus)
|
||||
}
|
||||
|
||||
if got := string(ctx.Response.Body()); got != tt.wantContent {
|
||||
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticHandler_TryFilesRootPathFallback 测试根路径回退
|
||||
func TestStaticHandler_TryFilesRootPathFallback(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// 创建 index.html 作为根路径回退
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("root fallback"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 注意:不创建 /.html 文件,测试根路径边界情况
|
||||
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
|
||||
handler.SetTryFiles([]string{"$uri", "$uri.html", "/index.html"}, false, nil)
|
||||
|
||||
ctx := newTestContext(t, "/")
|
||||
handler.Handle(ctx)
|
||||
|
||||
// 验证根路径请求正确回退到 /index.html
|
||||
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
|
||||
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
|
||||
}
|
||||
|
||||
if got := string(ctx.Response.Body()); got != "root fallback" {
|
||||
t.Errorf("内容 = %q, want %q", got, "root fallback")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user