feat(handler/static): 新增 alias 指令支持路径别名

- NewStaticHandlerWithAlias 创建带 alias 的静态文件处理器
- alias 与 root 互斥,支持路径替换而非附加
- 新增测试覆盖 alias 路径映射逻辑

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-08 14:37:20 +08:00
parent d48caf5183
commit 84d67c8570
2 changed files with 374 additions and 14 deletions

View File

@ -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)

View File

@ -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.pngimages/被替换)
},
}
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()