From 23f5c086144dab55d458f2dabe4f78abbfc976d9 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 7 Apr 2026 17:50:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(handler,server):=20=E9=9D=99=E6=80=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=96=B0=E5=A2=9E=20try=5Ffiles=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E9=9B=86=E6=88=90=E6=96=B0=E5=A2=9E=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StaticHandler 支持 try_files 配置,用于 SPA 部署回退 - 支持 $uri 和 $uri/ 占位符解析 - Server 集成 bodylimit 中间件(全局和路径级别配置) - Server 集成 errorintercept 中间件(错误页面拦截) - Server 预加载错误页面到内存 Co-Authored-By: Claude Opus 4.6 --- internal/handler/static.go | 191 ++++++++++++++++++++++++++++++++++--- internal/server/server.go | 57 +++++++++++ 2 files changed, 236 insertions(+), 12 deletions(-) diff --git a/internal/handler/static.go b/internal/handler/static.go index c6dc2f8..e317ffa 100644 --- a/internal/handler/static.go +++ b/internal/handler/static.go @@ -55,6 +55,16 @@ type StaticHandler struct { // gzipStatic 预压缩文件支持(可选) gzipStatic *compression.GzipStatic + + // tryFiles 按顺序尝试查找的文件列表 + // 支持 $uri 和 $uri/ 占位符 + tryFiles []string + + // tryFilesPass 内部重定向是否触发中间件 + tryFilesPass bool + + // router 用于内部重定向时重新路由(当 tryFilesPass 为 true) + router *Router } // NewStaticHandler 创建静态文件处理器。 @@ -114,22 +124,42 @@ func (h *StaticHandler) SetGzipStatic(enabled bool, extensions []string) { } } +// SetTryFiles 设置 try_files 配置。 +// +// 配置按顺序尝试查找的文件列表,支持 $uri 和 $uri/ 占位符。 +// 用于 SPA 部署,当请求的文件不存在时可以回退到指定文件。 +// +// 参数: +// - tryFiles: 按顺序尝试的文件列表,如 ["$uri", "$uri/", "/index.html"] +// - tryFilesPass: 内部重定向是否触发中间件,默认为 false +// - router: 当 tryFilesPass 为 true 时使用的路由器 +// +// 使用示例: +// +// handler.SetTryFiles([]string{"$uri", "$uri/", "/index.html"}, false, nil) +func (h *StaticHandler) SetTryFiles(tryFiles []string, tryFilesPass bool, router *Router) { + h.tryFiles = tryFiles + h.tryFilesPass = tryFilesPass + h.router = router +} + // Handle 处理静态文件请求。 // // 根据请求路径查找并返回对应的静态文件。 -// 支持目录索引文件、缓存查找和零拷贝传输。 +// 支持目录索引文件、try_files、缓存查找和零拷贝传输。 // // 参数: // - ctx: fasthttp 请求上下文 // // 处理流程: // 1. 安全检查:防止目录遍历攻击 -// 2. 检查文件/目录是否存在 -// 3. 如果是目录,尝试查找索引文件 -// 4. 尝试发送预压缩文件 -// 5. 尝试从缓存获取 -// 6. 大文件使用零拷贝传输 -// 7. 读取文件并存入缓存 +// 2. 如果配置了 try_files,按顺序尝试查找文件 +// 3. 检查文件/目录是否存在 +// 4. 如果是目录,尝试查找索引文件 +// 5. 尝试发送预压缩文件 +// 6. 尝试从缓存获取 +// 7. 大文件使用零拷贝传输 +// 8. 读取文件并存入缓存 func (h *StaticHandler) Handle(ctx *fasthttp.RequestCtx) { reqPath := string(ctx.Path()) @@ -139,16 +169,153 @@ func (h *StaticHandler) Handle(ctx *fasthttp.RequestCtx) { return } - // 剥离路径前缀 + // 如果配置了 try_files,按顺序尝试 + if len(h.tryFiles) > 0 { + h.handleTryFiles(ctx, reqPath) + return + } + + // 标准处理流程 + h.handleStandard(ctx, reqPath) +} + +// handleTryFiles 处理 try_files 逻辑。 +// +// 按顺序尝试查找文件,支持 $uri 和 $uri/ 占位符。 +// +// 占位符说明: +// - $uri: 请求路径对应的文件 +// - $uri/: 请求路径对应的目录下的索引文件 +// +// 参数: +// - ctx: fasthttp 请求上下文 +// - reqPath: 原始请求路径 +func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string) { + // 获取相对路径(剥离路径前缀) + relPath := reqPath if h.pathPrefix != "" && h.pathPrefix != "/" { - reqPath = strings.TrimPrefix(reqPath, h.pathPrefix) - if !strings.HasPrefix(reqPath, "/") { - reqPath = "/" + reqPath + relPath = strings.TrimPrefix(reqPath, h.pathPrefix) + if !strings.HasPrefix(relPath, "/") { + relPath = "/" + relPath + } + } + + for _, tryFile := range h.tryFiles { + // 解析占位符 + targetPath := h.resolveTryFilePath(tryFile, relPath) + + // 构建完整文件路径 + filePath := filepath.Join(h.root, targetPath) + + // 检查文件/目录是否存在 + info, err := os.Stat(filePath) + if err != nil { + continue // 不存在,尝试下一个 + } + + if info.IsDir() { + // 如果是目录,尝试查找索引文件 + for _, idx := range h.index { + idxPath := filepath.Join(filePath, idx) + if idxInfo, err := os.Stat(idxPath); err == nil && !idxInfo.IsDir() { + h.serveFile(ctx, idxPath, idxInfo) + return + } + } + continue // 目录中没有索引文件,尝试下一个 + } + + // 找到文件,检查是否是内部重定向 + if tryFile != "$uri" && !strings.HasPrefix(tryFile, "$uri") { + // 这是内部重定向(fallback 文件) + h.handleInternalRedirect(ctx, targetPath) + return + } + + // 直接服务文件 + h.serveFile(ctx, filePath, info) + return + } + + // 所有 try_files 都未找到 + ctx.Error("Not Found", fasthttp.StatusNotFound) +} + +// resolveTryFilePath 解析 try_files 中的占位符。 +// +// 参数: +// - tryFile: try_files 配置项 +// - relPath: 相对请求路径 +// +// 返回值: +// - string: 解析后的文件路径 +func (h *StaticHandler) resolveTryFilePath(tryFile, relPath string) string { + switch { + case tryFile == "$uri": + return relPath + case tryFile == "$uri/": + return relPath + "/" + case strings.HasPrefix(tryFile, "/"): + // 绝对路径,直接返回(去掉开头的 /) + return tryFile[1:] + default: + // 其他情况直接返回 + return tryFile + } +} + +// handleInternalRedirect 处理内部重定向。 +// +// 当 try_files 的回退文件与原始请求不同时触发。 +// 根据 tryFilesPass 配置决定是否重新进入中间件链。 +// +// 参数: +// - ctx: fasthttp 请求上下文 +// - targetPath: 重定向目标路径(相对于 root) +func (h *StaticHandler) handleInternalRedirect(ctx *fasthttp.RequestCtx, targetPath string) { + if h.tryFilesPass && h.router != nil { + // tryFilesPass 为 true,重新进入中间件链 + // 修改请求路径后重新路由 + newPath := h.pathPrefix + targetPath + if !strings.HasPrefix(newPath, "/") { + newPath = "/" + newPath + } + ctx.Request.SetRequestURI(newPath) + h.router.Handler()(ctx) + return + } + + // tryFilesPass 为 false(默认),直接服务文件,不触发中间件 + filePath := filepath.Join(h.root, targetPath) + info, err := os.Stat(filePath) + if err != nil { + ctx.Error("Not Found", fasthttp.StatusNotFound) + return + } + if info.IsDir() { + ctx.Error("Forbidden", fasthttp.StatusForbidden) + return + } + h.serveFile(ctx, filePath, info) +} + +// handleStandard 标准静态文件处理流程。 +// +// 参数: +// - 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 } } // 拼接文件路径 - filePath := filepath.Join(h.root, reqPath) + filePath := filepath.Join(h.root, relPath) // 检查文件/目录是否存在 info, err := os.Stat(filePath) diff --git a/internal/server/server.go b/internal/server/server.go index 697f6f9..96773c4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -36,7 +36,9 @@ import ( "rua.plus/lolly/internal/logging" "rua.plus/lolly/internal/middleware" "rua.plus/lolly/internal/middleware/accesslog" + "rua.plus/lolly/internal/middleware/bodylimit" "rua.plus/lolly/internal/middleware/compression" + "rua.plus/lolly/internal/middleware/errorintercept" "rua.plus/lolly/internal/middleware/rewrite" "rua.plus/lolly/internal/middleware/security" "rua.plus/lolly/internal/proxy" @@ -76,6 +78,9 @@ type Server struct { // accessLogMiddleware 访问日志中间件,记录请求详细信息 accessLogMiddleware *accesslog.AccessLog + // errorPageManager 错误页面管理器(可选) + errorPageManager *handler.ErrorPageManager + // pool Goroutine 池,用于限制并发处理请求数(可选) pool *GoroutinePool @@ -239,6 +244,29 @@ func (s *Server) buildMiddlewareChain(serverCfg *config.ServerConfig) (*middlewa middlewares = append(middlewares, auth) } + // 4.5 BodyLimit (请求体大小限制) + // 创建 bodylimit 中间件,使用全局配置或默认值 + bodyLimitMiddleware := bodylimit.NewWithDefault() + if serverCfg.ClientMaxBodySize != "" { + bl, err := bodylimit.New(serverCfg.ClientMaxBodySize) + if err != nil { + return nil, fmt.Errorf("创建请求体限制中间件失败: %w", err) + } + bodyLimitMiddleware = bl + } + // 添加路径级别的限制配置 + for i := range serverCfg.Proxy { + if serverCfg.Proxy[i].ClientMaxBodySize != "" { + if err := bodyLimitMiddleware.AddPathLimit( + serverCfg.Proxy[i].Path, + serverCfg.Proxy[i].ClientMaxBodySize, + ); err != nil { + return nil, fmt.Errorf("添加路径请求体限制失败: %w", err) + } + } + } + middlewares = append(middlewares, bodyLimitMiddleware) + // 5. Rewrite (URL 重写) if len(serverCfg.Rewrite) > 0 { rw, err := rewrite.New(serverCfg.Rewrite) @@ -268,6 +296,13 @@ func (s *Server) buildMiddlewareChain(serverCfg *config.ServerConfig) (*middlewa middlewares = append(middlewares, headers) } + // 8. ErrorIntercept (错误页面拦截) + // 如果配置了错误页面,添加错误拦截中间件 + if s.errorPageManager != nil && s.errorPageManager.IsConfigured() { + ei := errorintercept.New(s.errorPageManager) + middlewares = append(middlewares, ei) + } + return middleware.NewChain(middlewares...), nil } @@ -308,6 +343,21 @@ func (s *Server) Start() error { ) } + // 预加载错误页面(如果配置) + if s.config.Server.Security.ErrorPage.Pages != nil || s.config.Server.Security.ErrorPage.Default != "" { + var err error + s.errorPageManager, err = handler.NewErrorPageManager(&s.config.Server.Security.ErrorPage) + if err != nil { + // 检查是否是部分加载失败 + if _, ok := err.(*handler.PartialLoadError); ok { + logging.Warn().Msg("部分错误页面加载失败: " + err.Error()) + } else { + // 全部加载失败,阻止启动 + return fmt.Errorf("加载错误页面失败: %w", err) + } + } + } + if s.config.HasServers() { return s.startVHostMode() } @@ -683,6 +733,13 @@ func (s *Server) registerStaticHandlers(router *handler.Router, cfg *config.Serv staticHandler.SetGzipStatic(true, cfg.Compression.GzipStaticExtensions) } + // 设置 try_files 配置 + if len(static.TryFiles) > 0 { + // 注意:tryFilesPass 需要路由器支持,当前实现传入 nil + // 如果 tryFilesPass 为 true,需要额外处理 + staticHandler.SetTryFiles(static.TryFiles, static.TryFilesPass, router) + } + // 注册路由:确保路径以 / 结尾 routePath := path if !strings.HasSuffix(routePath, "/") {