feat(handler,server): 静态文件新增 try_files 配置,集成新增中间件

- StaticHandler 支持 try_files 配置,用于 SPA 部署回退
- 支持 $uri 和 $uri/ 占位符解析
- Server 集成 bodylimit 中间件(全局和路径级别配置)
- Server 集成 errorintercept 中间件(错误页面拦截)
- Server 预加载错误页面到内存

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-07 17:50:38 +08:00
parent 827d26c2b5
commit 23f5c08614
2 changed files with 236 additions and 12 deletions

View File

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

View File

@ -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, "/") {