lolly/internal/handler/static.go
xfy fa55bfd497 feat(cache): 添加缓存 TTL 新鲜度验证优化
在 FileEntry 中新增 CachedAt 字段记录缓存时间,实现 TTL 窗口内的缓存新鲜度验证:
- TTL 内跳过 ModTime 验证,减少 os.Stat 调用
- TTL 过期后验证 ModTime,文件未修改时刷新 CachedAt
- 默认 TTL 设置为 5 秒

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 17:53:51 +08:00

635 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package handler 提供 HTTP 请求处理器,包括路由、静态文件服务和零拷贝传输。
//
// 该文件包含静态文件服务相关的核心逻辑,包括:
// - 静态文件请求处理
// - 目录索引文件支持
// - 文件缓存和零拷贝传输优化
// - 预压缩文件支持
//
// 主要用途:
//
// 用于提供静态文件服务,支持缓存和零拷贝传输优化。
//
// 注意事项:
// - 自动处理目录遍历攻击防护
// - 支持多索引文件(如 index.html、index.htm
// - 支持预压缩 .gz 文件
//
// 作者xfy
package handler
import (
"os"
"path/filepath"
"strings"
"time"
"github.com/valyala/fasthttp"
"rua.plus/lolly/internal/cache"
"rua.plus/lolly/internal/middleware/compression"
"rua.plus/lolly/internal/mimeutil"
"rua.plus/lolly/internal/utils"
)
// StaticHandler 静态文件处理器。
//
// 提供静态文件服务,支持目录索引、文件缓存和零拷贝传输。
//
// 注意事项:
// - 自动处理目录遍历攻击防护(拒绝包含 ".." 的路径)
// - 并发安全,可在多个 goroutine 中使用
// - 大文件(>= 8KB自动启用零拷贝传输
// - alias 与 root 互斥,同时配置时 alias 优先
type StaticHandler struct {
fileCache *cache.FileCache
cacheTTL time.Duration // 缓存新鲜度 TTL默认 5s0 表示每次验证 ModTime
gzipStatic *compression.GzipStatic
router *Router
root string
alias string
pathPrefix string
index []string
tryFiles []string
useSendfile bool
tryFilesPass bool
symlinkCheck bool
}
// NewStaticHandler 创建静态文件处理器。
//
// 初始化并返回一个新的静态文件处理器实例。
//
// 参数:
// - root: 静态文件根目录路径
// - pathPrefix: 路径前缀,会被剥离后拼接 root
// - index: 索引文件列表,当请求目录时依次查找(如 ["index.html", "index.htm"]
// - useSendfile: 是否启用零拷贝传输(大文件优化)
//
// 返回值:
// - *StaticHandler: 新创建的静态文件处理器
//
// 使用示例:
//
// handler := handler.NewStaticHandler("/var/www", "/", []string{"index.html"}, true)
func NewStaticHandler(root, pathPrefix string, index []string, useSendfile bool) *StaticHandler {
return &StaticHandler{
root: root,
pathPrefix: pathPrefix,
index: index,
useSendfile: useSendfile,
}
}
// 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 设置文件缓存。
//
// 为静态文件处理器启用文件缓存功能。
// 缓存可以显著提升小文件的访问性能。
//
// 参数:
// - fc: 文件缓存实例
//
// 注意事项:
// - 仅对小于 1MB 的文件启用缓存
// - 缓存会自动检测文件修改并更新
func (h *StaticHandler) SetFileCache(fc *cache.FileCache) {
h.fileCache = fc
}
// SetGzipStatic 设置预压缩文件支持。
//
// 启用后,对于匹配扩展名的请求,优先发送 .gz 预压缩文件。
//
// 参数:
// - enabled: 是否启用预压缩支持
// - extensions: 需要支持预压缩的文件扩展名列表(如 [".html", ".css", ".js"]
//
// 使用示例:
//
// handler.SetGzipStatic(true, []string{".html", ".css", ".js"})
func (h *StaticHandler) SetGzipStatic(enabled bool, extensions []string) {
if enabled {
h.gzipStatic = compression.NewGzipStatic(true, h.root, extensions)
}
}
// 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
}
// SetSymlinkCheck 设置符号链接安全检查。
//
// 启用后,服务文件前会验证符号链接指向的文件是否在允许的根目录范围内。
// 防止通过符号链接访问敏感文件(如 /etc/passwd
//
// 参数:
// - enabled: 是否启用符号链接安全检查
func (h *StaticHandler) SetSymlinkCheck(enabled bool) {
h.symlinkCheck = enabled
}
// SetCacheTTL 设置缓存新鲜度 TTL。
//
// TTL 控制缓存条目的新鲜度验证间隔。
// 在 TTL 窗口内,缓存命中时跳过 ModTime 验证以减少 os.Stat 调用。
//
// 参数:
// - ttl: TTL 时间间隔
//
// TTL 值说明:
// - ttl > 0: TTL 内跳过 ModTime 验证,过期后验证
// - ttl = 0: 每次请求验证 ModTime向后兼容
//
// 默认 TTL 为 5 秒。
func (h *StaticHandler) SetCacheTTL(ttl time.Duration) {
h.cacheTTL = ttl
}
// Handle 处理静态文件请求。
//
// 根据请求路径查找并返回对应的静态文件。
// 支持目录索引文件、try_files、缓存查找和零拷贝传输。
//
// 参数:
// - ctx: fasthttp 请求上下文
//
// 处理流程:
// 1. 安全检查:防止目录遍历攻击
// 2. 如果配置了 try_files按顺序尝试查找文件
// 3. 检查文件/目录是否存在
// 4. 如果是目录,尝试查找索引文件
// 5. 尝试发送预压缩文件
// 6. 尝试从缓存获取
// 7. 大文件使用零拷贝传输
// 8. 读取文件并存入缓存
func (h *StaticHandler) Handle(ctx *fasthttp.RequestCtx) {
reqPath := string(ctx.Path())
// 安全检查:防止目录遍历
if strings.Contains(reqPath, "..") {
utils.SendError(ctx, utils.ErrForbidden)
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 != "/" {
relPath = strings.TrimPrefix(reqPath, h.pathPrefix)
if !strings.HasPrefix(relPath, "/") {
relPath = "/" + relPath
}
}
for _, tryFile := range h.tryFiles {
// 解析占位符
targetPath := h.resolveTryFilePath(tryFile, relPath)
// 构建完整文件路径(支持 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)
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 都未找到
utils.SendError(ctx, utils.ErrNotFound)
}
// resolveTryFilePath 解析 try_files 中的占位符。
//
// 支持的占位符:
// - $uri: 请求路径
// - $uri/: 请求路径加斜杠
// - $uri.<ext>: 请求路径加扩展名(如 $uri.html
//
// nginx 兼容性说明:
// - $uri 变量语义与 nginx try_files 一致
// - 附加安全验证在 validateStatics 时执行
//
// 参数:
// - tryFile: try_files 配置项(已在 validateStatics 时验证)
// - 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, "$uri."):
// 提取后缀部分(包含点,如 ".html"
suffix := tryFile[4:] // "$uri" 是4个字符后面是 ".html" 等后缀
// 根路径边界处理:返回空字符串让 try_files 继续下一个条目
// 避免 "/.html" 这样的隐藏文件名
if relPath == "/" {
return "" // 触发回退到下一个 try_files 条目
}
return relPath + suffix
// ====== 保留:现有逻辑 ======
case strings.HasPrefix(tryFile, "/"):
// 绝对路径,直接返回(去掉开头的 /
return tryFile[1:]
default:
// 其他情况直接返回
return tryFile
}
}
// handleInternalRedirect 处理内部重定向。
//
// 当 try_files 的回退文件与原始请求不同时触发。
// 根据 tryFilesPass 配置决定是否重新进入中间件链。
//
// 参数:
// - ctx: fasthttp 请求上下文
// - targetPath: 重定向目标路径(相对于 root 或 alias
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默认直接服务文件不触发中间件
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 {
utils.SendError(ctx, utils.ErrNotFound)
return
}
if info.IsDir() {
utils.SendError(ctx, utils.ErrForbidden)
return
}
h.serveFile(ctx, filePath, info)
}
// handleStandard 标准静态文件处理流程。
//
// 参数:
// - ctx: fasthttp 请求上下文
// - reqPath: 请求路径
func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string) {
// 计算文件路径
var filePath string
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)
if err != nil {
utils.SendError(ctx, utils.ErrNotFound)
return
}
// 符号链接安全检查
if h.symlinkCheck {
if err := h.validateSymlink(filePath); err != nil {
utils.SendError(ctx, utils.ErrForbidden)
return
}
}
// 如果是目录,尝试索引文件
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
}
}
utils.SendError(ctx, utils.ErrForbidden)
return
}
// Phase 2: 缓存查找 + TTL 验证
// 在 serveFile 调用前检查缓存,减少 os.ReadFile 调用
// 注意: CachedAt 迁移已在 FileCache.Get() 内部完成,确保并发安全
if h.fileCache != nil {
if entry, ok := h.fileCache.Get(filePath); ok {
// TTL 验证cacheTTL > 0 时启用)
if h.cacheTTL > 0 && time.Since(entry.CachedAt) < h.cacheTTL {
// TTL 内直接返回(无需验证 ModTime
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
return
}
// TTL 过期或未启用 TTL验证文件新鲜度
if entry.ModTime.Equal(info.ModTime()) {
// 文件未修改,刷新 TTL 并返回
if h.cacheTTL > 0 {
h.fileCache.RefreshCachedAt(filePath)
}
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
return
}
// 文件已修改,删除缓存继续处理
h.fileCache.Delete(filePath)
}
}
// Phase 3: 缓存未命中,调用 serveFile 处理
h.serveFile(ctx, filePath, info)
}
// serveFile 提供文件服务,支持缓存和零拷贝传输。
//
// 内部方法,负责实际的文件发送逻辑。
//
// 参数:
// - ctx: fasthttp 请求上下文
// - filePath: 文件绝对路径
// - info: 文件信息(用于判断文件大小和修改时间)
func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, info os.FileInfo) {
// 尝试发送预压缩文件
if h.gzipStatic != nil {
relPath := strings.TrimPrefix(filePath, h.root)
if h.gzipStatic.ServeFile(ctx, relPath) {
return // 预压缩文件已发送
}
}
// 尝试从缓存获取
if h.fileCache != nil {
if entry, ok := h.fileCache.Get(filePath); ok {
// 检查文件是否被修改
if entry.ModTime.Equal(info.ModTime()) {
// 缓存命中且文件未修改
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
return
}
// 文件已修改,删除旧缓存
h.fileCache.Delete(filePath)
}
}
// 大文件使用零拷贝传输
if h.useSendfile && info.Size() >= MinSendfileSize {
// 设置 Content-Type (sendfile 不会自动设置)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
file, err := os.Open(filePath)
if err == nil {
defer func() {
_ = file.Close()
}()
if err := SendFile(ctx, file, 0, info.Size()); err == nil {
return
}
// sendfile 失败fallback 到 ServeFile
}
}
// 读取文件内容
data, err := os.ReadFile(filePath)
if err != nil {
utils.SendError(ctx, utils.ErrInternalError)
return
}
// 存入缓存(仅对小文件缓存)
if h.fileCache != nil && info.Size() < 1024*1024 { // < 1MB
_ = h.fileCache.Set(filePath, data, info.Size(), info.ModTime())
}
ctx.Response.SetBody(data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
}
// validateSymlink 验证符号链接是否安全。
//
// 检查文件是否是符号链接,如果是则验证链接指向的文件
// 是否在允许的根目录root 或 alias范围内。
// 防止通过符号链接访问敏感文件(如 /etc/passwd
//
// 参数:
// - filePath: 要验证的文件路径
//
// 返回值:
// - error: 如果符号链接不安全或解析失败,返回错误
func (h *StaticHandler) validateSymlink(filePath string) error {
// 获取文件信息(不跟随符号链接)
info, err := os.Lstat(filePath)
if err != nil {
return err
}
// 如果不是符号链接,直接返回成功
if info.Mode()&os.ModeSymlink == 0 {
return nil
}
// 获取符号链接指向的实际路径
realPath, err := filepath.EvalSymlinks(filePath)
if err != nil {
return err
}
// 获取允许的基础路径
basePath := h.root
if h.alias != "" {
basePath = h.alias
}
// 如果没有配置根目录,拒绝符号链接
if basePath == "" {
return os.ErrPermission
}
// 解析基础路径为绝对路径
absBase, err := filepath.Abs(basePath)
if err != nil {
return err
}
// 解析目标路径为绝对路径
absTarget, err := filepath.Abs(realPath)
if err != nil {
return err
}
// 确保目标路径在基础路径范围内
if !strings.HasPrefix(absTarget, absBase+string(filepath.Separator)) && absTarget != absBase {
return os.ErrPermission
}
return nil
}