From 84d67c8570fdc3c77508ea88cc89c75d73269e88 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 8 Apr 2026 14:37:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(handler/static):=20=E6=96=B0=E5=A2=9E=20al?= =?UTF-8?q?ias=20=E6=8C=87=E4=BB=A4=E6=94=AF=E6=8C=81=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewStaticHandlerWithAlias 创建带 alias 的静态文件处理器 - alias 与 root 互斥,支持路径替换而非附加 - 新增测试覆盖 alias 路径映射逻辑 Co-Authored-By: Claude --- internal/handler/static.go | 125 +++++++++++++-- internal/handler/static_test.go | 263 ++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+), 14 deletions(-) diff --git a/internal/handler/static.go b/internal/handler/static.go index e317ffa..1c823e7 100644 --- a/internal/handler/static.go +++ b/internal/handler/static.go @@ -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) diff --git a/internal/handler/static_test.go b/internal/handler/static_test.go index 68968e9..8d954a2 100644 --- a/internal/handler/static_test.go +++ b/internal/handler/static_test.go @@ -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()