feat(handler/static): 新增 alias 指令支持路径别名
- NewStaticHandlerWithAlias 创建带 alias 的静态文件处理器 - alias 与 root 互斥,支持路径替换而非附加 - 新增测试覆盖 alias 路径映射逻辑 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d48caf5183
commit
84d67c8570
@ -37,10 +37,16 @@ import (
|
||||
// - 自动处理目录遍历攻击防护(拒绝包含 ".." 的路径)
|
||||
// - 并发安全,可在多个 goroutine 中使用
|
||||
// - 大文件(>= 8KB)自动启用零拷贝传输
|
||||
// - alias 与 root 互斥,同时配置时 alias 优先
|
||||
type StaticHandler struct {
|
||||
// root 静态文件根目录
|
||||
root string
|
||||
|
||||
// alias 路径别名(与 root 互斥)
|
||||
// 例如:path: "/images/", alias: "/var/www/img/"
|
||||
// 请求 "/images/logo.png" -> 文件 "/var/www/img/logo.png"
|
||||
alias string
|
||||
|
||||
// pathPrefix 路径前缀,会被剥离后拼接 root
|
||||
pathPrefix string
|
||||
|
||||
@ -92,6 +98,68 @@ func NewStaticHandler(root, pathPrefix string, index []string, useSendfile bool)
|
||||
}
|
||||
}
|
||||
|
||||
// NewStaticHandlerWithAlias 创建带 alias 的静态文件处理器。
|
||||
//
|
||||
// alias 与 root 的区别:
|
||||
// - root: 请求路径附加到 root 后
|
||||
// 例如:root "/var/www", 请求 "/images/logo.png" -> "/var/www/images/logo.png"
|
||||
// - alias: 请求路径替换匹配部分
|
||||
// 例如:alias "/var/www/img/", 请求 "/images/logo.png" -> "/var/www/img/logo.png"
|
||||
//
|
||||
// 参数:
|
||||
// - alias: 路径别名
|
||||
// - pathPrefix: 路径前缀,用于匹配和替换
|
||||
// - index: 索引文件列表
|
||||
// - useSendfile: 是否启用零拷贝传输
|
||||
//
|
||||
// 使用示例:
|
||||
//
|
||||
// handler := handler.NewStaticHandlerWithAlias("/var/www/img/", "/images/", []string{"index.html"}, true)
|
||||
func NewStaticHandlerWithAlias(alias, pathPrefix string, index []string, useSendfile bool) *StaticHandler {
|
||||
return &StaticHandler{
|
||||
alias: alias,
|
||||
pathPrefix: pathPrefix,
|
||||
index: index,
|
||||
useSendfile: useSendfile,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAlias 设置路径别名。
|
||||
//
|
||||
// alias 与 root 互斥,设置 alias 会清空 root。
|
||||
//
|
||||
// 参数:
|
||||
// - alias: 路径别名
|
||||
func (h *StaticHandler) SetAlias(alias string) {
|
||||
h.alias = alias
|
||||
if alias != "" {
|
||||
h.root = ""
|
||||
}
|
||||
}
|
||||
|
||||
// SetRoot 设置静态文件根目录。
|
||||
//
|
||||
// root 与 alias 互斥,设置 root 会清空 alias。
|
||||
//
|
||||
// 参数:
|
||||
// - root: 静态文件根目录
|
||||
func (h *StaticHandler) SetRoot(root string) {
|
||||
h.root = root
|
||||
if root != "" {
|
||||
h.alias = ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetAlias 获取路径别名。
|
||||
func (h *StaticHandler) GetAlias() string {
|
||||
return h.alias
|
||||
}
|
||||
|
||||
// GetRoot 获取静态文件根目录。
|
||||
func (h *StaticHandler) GetRoot() string {
|
||||
return h.root
|
||||
}
|
||||
|
||||
// SetFileCache 设置文件缓存。
|
||||
//
|
||||
// 为静态文件处理器启用文件缓存功能。
|
||||
@ -204,8 +272,13 @@ func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string)
|
||||
// 解析占位符
|
||||
targetPath := h.resolveTryFilePath(tryFile, relPath)
|
||||
|
||||
// 构建完整文件路径
|
||||
filePath := filepath.Join(h.root, targetPath)
|
||||
// 构建完整文件路径(支持 alias 和 root)
|
||||
var filePath string
|
||||
if h.alias != "" {
|
||||
filePath = filepath.Join(h.alias, targetPath)
|
||||
} else {
|
||||
filePath = filepath.Join(h.root, targetPath)
|
||||
}
|
||||
|
||||
// 检查文件/目录是否存在
|
||||
info, err := os.Stat(filePath)
|
||||
@ -271,7 +344,7 @@ func (h *StaticHandler) resolveTryFilePath(tryFile, relPath string) string {
|
||||
//
|
||||
// 参数:
|
||||
// - ctx: fasthttp 请求上下文
|
||||
// - targetPath: 重定向目标路径(相对于 root)
|
||||
// - targetPath: 重定向目标路径(相对于 root 或 alias)
|
||||
func (h *StaticHandler) handleInternalRedirect(ctx *fasthttp.RequestCtx, targetPath string) {
|
||||
if h.tryFilesPass && h.router != nil {
|
||||
// tryFilesPass 为 true,重新进入中间件链
|
||||
@ -286,7 +359,12 @@ func (h *StaticHandler) handleInternalRedirect(ctx *fasthttp.RequestCtx, targetP
|
||||
}
|
||||
|
||||
// tryFilesPass 为 false(默认),直接服务文件,不触发中间件
|
||||
filePath := filepath.Join(h.root, targetPath)
|
||||
var filePath string
|
||||
if h.alias != "" {
|
||||
filePath = filepath.Join(h.alias, targetPath)
|
||||
} else {
|
||||
filePath = filepath.Join(h.root, targetPath)
|
||||
}
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
ctx.Error("Not Found", fasthttp.StatusNotFound)
|
||||
@ -305,17 +383,36 @@ func (h *StaticHandler) handleInternalRedirect(ctx *fasthttp.RequestCtx, targetP
|
||||
// - ctx: fasthttp 请求上下文
|
||||
// - reqPath: 请求路径
|
||||
func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string) {
|
||||
// 剥离路径前缀
|
||||
relPath := reqPath
|
||||
if h.pathPrefix != "" && h.pathPrefix != "/" {
|
||||
relPath = strings.TrimPrefix(reqPath, h.pathPrefix)
|
||||
if !strings.HasPrefix(relPath, "/") {
|
||||
relPath = "/" + relPath
|
||||
}
|
||||
}
|
||||
// 计算文件路径
|
||||
var filePath string
|
||||
|
||||
// 拼接文件路径
|
||||
filePath := filepath.Join(h.root, relPath)
|
||||
if h.alias != "" {
|
||||
// alias 模式:将匹配的路径前缀替换为 alias
|
||||
// 例如:path: "/images/", alias: "/var/www/img/"
|
||||
// 请求 "/images/logo.png" -> 文件 "/var/www/img/logo.png"
|
||||
relPath := reqPath
|
||||
if h.pathPrefix != "" && h.pathPrefix != "/" {
|
||||
relPath = strings.TrimPrefix(reqPath, h.pathPrefix)
|
||||
if !strings.HasPrefix(relPath, "/") {
|
||||
relPath = "/" + relPath
|
||||
}
|
||||
}
|
||||
// 使用 alias 替换匹配部分
|
||||
filePath = filepath.Join(h.alias, relPath)
|
||||
} else {
|
||||
// root 模式:将请求路径附加到 root
|
||||
// 剥离路径前缀
|
||||
relPath := reqPath
|
||||
if h.pathPrefix != "" && h.pathPrefix != "/" {
|
||||
relPath = strings.TrimPrefix(reqPath, h.pathPrefix)
|
||||
if !strings.HasPrefix(relPath, "/") {
|
||||
relPath = "/" + relPath
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接文件路径
|
||||
filePath = filepath.Join(h.root, relPath)
|
||||
}
|
||||
|
||||
// 检查文件/目录是否存在
|
||||
info, err := os.Stat(filePath)
|
||||
|
||||
@ -1099,6 +1099,269 @@ func TestStaticHandler_TryFilesWithPathPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticHandler_Alias 测试 alias 路径替换功能
|
||||
func TestStaticHandler_Alias(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, aliasDir string)
|
||||
alias string
|
||||
pathPrefix string
|
||||
path string
|
||||
wantStatus int
|
||||
wantContent string
|
||||
skipContent bool
|
||||
}{
|
||||
{
|
||||
name: "alias 基础替换",
|
||||
setup: func(t *testing.T, aliasDir string) {
|
||||
if err := os.WriteFile(filepath.Join(aliasDir, "logo.png"), []byte("png content"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
},
|
||||
alias: "/alias/images/",
|
||||
pathPrefix: "/images/",
|
||||
path: "/images/logo.png",
|
||||
wantStatus: fasthttp.StatusOK,
|
||||
wantContent: "png content",
|
||||
},
|
||||
{
|
||||
name: "alias 嵌套路径",
|
||||
setup: func(t *testing.T, aliasDir string) {
|
||||
subDir := filepath.Join(aliasDir, "icons")
|
||||
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||
t.Fatalf("创建目录失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(subDir, "app.png"), []byte("app icon"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
},
|
||||
alias: "/alias/img/",
|
||||
pathPrefix: "/images/",
|
||||
path: "/images/icons/app.png",
|
||||
wantStatus: fasthttp.StatusOK,
|
||||
wantContent: "app icon",
|
||||
},
|
||||
{
|
||||
name: "alias 目录索引",
|
||||
setup: func(t *testing.T, aliasDir string) {
|
||||
if err := os.WriteFile(filepath.Join(aliasDir, "index.html"), []byte("alias index"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
},
|
||||
alias: "/alias/images/",
|
||||
pathPrefix: "/images/",
|
||||
path: "/images/",
|
||||
wantStatus: fasthttp.StatusOK,
|
||||
wantContent: "alias index",
|
||||
},
|
||||
{
|
||||
name: "alias 文件不存在",
|
||||
setup: func(t *testing.T, aliasDir string) {
|
||||
// 不创建任何文件
|
||||
},
|
||||
alias: "/alias/images/",
|
||||
pathPrefix: "/images/",
|
||||
path: "/images/notfound.png",
|
||||
wantStatus: fasthttp.StatusNotFound,
|
||||
skipContent: true,
|
||||
},
|
||||
{
|
||||
name: "root 与 alias 互斥 - 使用 alias",
|
||||
setup: func(t *testing.T, aliasDir string) {
|
||||
if err := os.WriteFile(filepath.Join(aliasDir, "file.txt"), []byte("from alias"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
},
|
||||
alias: "/alias/images/",
|
||||
pathPrefix: "/images/",
|
||||
path: "/images/file.txt",
|
||||
wantStatus: fasthttp.StatusOK,
|
||||
wantContent: "from alias",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 创建临时目录作为 alias 目录
|
||||
aliasDir := t.TempDir()
|
||||
tt.setup(t, aliasDir)
|
||||
|
||||
// 创建处理器(使用 alias)
|
||||
handler := NewStaticHandlerWithAlias(aliasDir, tt.pathPrefix, []string{"index.html"}, false)
|
||||
|
||||
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 !tt.skipContent && tt.wantContent != "" {
|
||||
got := string(ctx.Response.Body())
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticHandler_AliasVsRoot 测试 alias 和 root 的行为区别
|
||||
func TestStaticHandler_AliasVsRoot(t *testing.T) {
|
||||
// root 目录
|
||||
rootDir := t.TempDir()
|
||||
// alias 目录
|
||||
aliasDir := t.TempDir()
|
||||
|
||||
// 在 root 创建子目录和文件
|
||||
imagesDir := filepath.Join(rootDir, "images")
|
||||
if err := os.MkdirAll(imagesDir, 0755); err != nil {
|
||||
t.Fatalf("创建 images 目录失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(imagesDir, "logo.png"), []byte("from root"), 0644); err != nil {
|
||||
t.Fatalf("创建 root 文件失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(aliasDir, "logo.png"), []byte("from alias"), 0644); err != nil {
|
||||
t.Fatalf("创建 alias 文件失败: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
handler *StaticHandler
|
||||
path string
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "root 模式:请求路径附加到 root",
|
||||
handler: NewStaticHandler(rootDir, "/", []string{"index.html"}, false),
|
||||
path: "/images/logo.png",
|
||||
wantContent: "from root", // /var/www/images/logo.png
|
||||
},
|
||||
{
|
||||
name: "alias 模式:请求路径替换匹配部分",
|
||||
handler: NewStaticHandlerWithAlias(aliasDir, "/images/", []string{"index.html"}, false),
|
||||
path: "/images/logo.png",
|
||||
wantContent: "from alias", // /alias/logo.png(images/被替换)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := newTestContext(t, tt.path)
|
||||
tt.handler.Handle(ctx)
|
||||
|
||||
got := string(ctx.Response.Body())
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticHandler_SetAlias 测试 SetAlias 方法
|
||||
func TestStaticHandler_SetAlias(t *testing.T) {
|
||||
t.Run("设置 alias", func(t *testing.T) {
|
||||
handler := NewStaticHandler("/root", "/", nil, false)
|
||||
|
||||
if handler.GetAlias() != "" {
|
||||
t.Error("初始 alias 应为空")
|
||||
}
|
||||
|
||||
handler.SetAlias("/alias")
|
||||
|
||||
if handler.GetAlias() != "/alias" {
|
||||
t.Errorf("GetAlias() = %q, want %q", handler.GetAlias(), "/alias")
|
||||
}
|
||||
if handler.GetRoot() != "" {
|
||||
t.Error("设置 alias 后 root 应被清空")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("设置 root 清除 alias", func(t *testing.T) {
|
||||
handler := NewStaticHandlerWithAlias("/alias", "/", nil, false)
|
||||
|
||||
if handler.GetAlias() != "/alias" {
|
||||
t.Error("初始 alias 应为 /alias")
|
||||
}
|
||||
|
||||
handler.SetRoot("/root")
|
||||
|
||||
if handler.GetRoot() != "/root" {
|
||||
t.Errorf("GetRoot() = %q, want %q", handler.GetRoot(), "/root")
|
||||
}
|
||||
if handler.GetAlias() != "" {
|
||||
t.Error("设置 root 后 alias 应被清空")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStaticHandler_AliasWithTryFiles 测试 alias 与 try_files 组合
|
||||
func TestStaticHandler_AliasWithTryFiles(t *testing.T) {
|
||||
aliasDir := t.TempDir()
|
||||
|
||||
// 创建测试文件
|
||||
if err := os.WriteFile(filepath.Join(aliasDir, "app.js"), []byte("app content"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(aliasDir, "index.html"), []byte("fallback"), 0644); err != nil {
|
||||
t.Fatalf("创建文件失败: %v", err)
|
||||
}
|
||||
|
||||
handler := NewStaticHandlerWithAlias(aliasDir, "/static/", []string{"index.html"}, false)
|
||||
handler.SetTryFiles([]string{"$uri", "/index.html"}, false, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "找到文件",
|
||||
path: "/static/app.js",
|
||||
wantStatus: fasthttp.StatusOK,
|
||||
wantContent: "app content",
|
||||
},
|
||||
{
|
||||
name: "回退到 index.html",
|
||||
path: "/static/nonexistent",
|
||||
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)
|
||||
}
|
||||
|
||||
got := string(ctx.Response.Body())
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewStaticHandlerWithAlias 测试 NewStaticHandlerWithAlias 构造函数
|
||||
func TestNewStaticHandlerWithAlias(t *testing.T) {
|
||||
handler := NewStaticHandlerWithAlias("/var/www/img/", "/images/", []string{"index.html"}, true)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("NewStaticHandlerWithAlias() 返回 nil")
|
||||
}
|
||||
if handler.GetAlias() != "/var/www/img/" {
|
||||
t.Errorf("GetAlias() = %q, want %q", handler.GetAlias(), "/var/www/img/")
|
||||
}
|
||||
if handler.GetRoot() != "" {
|
||||
t.Errorf("GetRoot() = %q, want 空字符串", handler.GetRoot())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticHandler_TryFilesEdgeCases 测试 try_files 边界情况
|
||||
func TestStaticHandler_TryFilesEdgeCases(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user