xfy f33117b940 fix(handler,http2,loadbalance,logging,resolver,ssl): fix high severity issues
- handler/static.go: add sync.RWMutex to StaticHandler; protect Handle
  with RLock and all setters with Lock to prevent data races
- http2/server.go: delete empty connection slice keys from pool map to
  prevent memory leak under high client churn
- loadbalance/slow_start.go: recreate stopCh in Start() to support
  Start-Stop-Start cycles; guard double-close in Stop()
- resolver/resolver.go: recreate stopCh in Start() to support restart
- logging/logging.go: save *os.File handles from getOutput so Close()
  actually closes log files; exclude os.Stdout/os.Stderr from closing
- ssl/session_tickets.go: protect started/rotateTimer access in
  scheduleRotation with mu; support Start-Stop-Start cycles
- ssl/ssl.go: cache parsed default certificate to avoid re-parsing on
  every TLS handshake for OCSP stapling
2026-06-11 17:03:17 +08:00

874 lines
25 KiB
Go
Raw Permalink 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 (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"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"
)
const httpTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
// Expires directive constants
const (
expiresOff = "off"
expiresMax = "max"
)
// statWithCache 从 FileInfo 缓存获取文件信息,支持负缓存。
//
// 返回值:
// - info: 文件信息(命中负缓存或出错时为 nil
// - ok: true 表示文件存在且缓存命中
// - err: 错误信息(命中负缓存时返回 nil表示已知文件不存在
func (h *StaticHandler) statWithCache(filePath string) (os.FileInfo, bool, error) {
if h.fileInfoCache == nil {
info, err := os.Stat(filePath)
if err != nil {
return nil, false, err
}
return info, true, nil
}
info, hit, exists := h.fileInfoCache.GetWithNotFound(filePath)
if hit {
if exists {
return info, true, nil
}
// 命中负缓存:已知文件不存在
return nil, false, os.ErrNotExist
}
// 缓存未命中,调用 os.Stat
info, err := os.Stat(filePath)
if err != nil {
h.fileInfoCache.SetWithNotFound(filePath, nil, true)
return nil, false, err
}
h.fileInfoCache.SetWithNotFound(filePath, info, false)
return info, true, nil
}
// StaticHandler 静态文件处理器。
//
// 提供静态文件服务,支持目录索引、文件缓存和零拷贝传输。
//
// 注意事项:
// - 自动处理目录遍历攻击防护(拒绝包含 ".." 的路径)
// - 并发安全,可在多个 goroutine 中使用
// - 大文件(>= 8KB自动启用零拷贝传输
// - alias 与 root 互斥,同时配置时 alias 优先
type StaticHandler struct {
mu sync.RWMutex // 保护以下字段的并发访问
// 指针类型字段(按大小排列)
fileCache *cache.FileCache
fileInfoCache *FileInfoCache // FileInfo 缓存,减少 os.Stat 调用
gzipStatic *compression.GzipStatic
router *Router
// 字符串字段
root string
alias string
pathPrefix string
expires string // 缓存过期时间nginx 兼容格式)
// 切片字段
index []string
tryFiles []string
// AutoIndex 配置
autoIndex bool
autoIndexFormat string
autoIndexLocaltime bool
autoIndexExactSize bool
// 基本类型字段
pathPrefixLen int // 预计算的路径前缀长度,用于零分配路径剥离
cacheTTL time.Duration // 缓存新鲜度 TTL默认 5s0 表示每次验证 ModTime
useSendfile bool
tryFilesPass bool
symlinkCheck bool
internal 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 {
// 规范化 root 路径,确保 TrimPrefix 能正确工作
// filepath.Clean 会去掉 ./ 并规范化路径分隔符
cleanRoot := filepath.Clean(root)
if !strings.HasSuffix(cleanRoot, string(filepath.Separator)) {
cleanRoot += string(filepath.Separator)
}
// 预计算前缀长度,用于零分配路径剥离
prefixLen := len(pathPrefix)
if pathPrefix == "/" {
prefixLen = 0 // 根路径无需剥离
}
h := &StaticHandler{
root: cleanRoot,
pathPrefix: pathPrefix,
pathPrefixLen: prefixLen,
index: index,
useSendfile: useSendfile,
fileInfoCache: NewFileInfoCache(),
}
// 默认 cacheTTL=0每次验证 ModTime同步禁用 fileInfoCache
h.fileInfoCache.SetTTL(0)
return h
}
// SetAlias 设置路径别名。
//
// alias 与 root 互斥,设置 alias 会清空 root。
//
// 参数:
// - alias: 路径别名
func (h *StaticHandler) SetAlias(alias string) {
h.mu.Lock()
defer h.mu.Unlock()
h.alias = alias
if alias != "" {
h.root = ""
}
}
// stripPathPrefix 剥离路径前缀(零分配)。
// 使用切片替代 strings.TrimPrefix避免内存分配。
func (h *StaticHandler) stripPathPrefix(reqPath string) string {
relPath := reqPath
if h.pathPrefixLen > 0 {
relPath = reqPath[h.pathPrefixLen:]
if len(relPath) > 0 && relPath[0] != '/' {
relPath = "/" + relPath
}
}
return relPath
}
// buildFilePath 构建完整文件路径。
// 支持 alias 和 root 两种模式。
func (h *StaticHandler) buildFilePath(relPath string) string {
if h.alias != "" {
return filepath.Join(h.alias, relPath)
}
return filepath.Join(h.root, relPath)
}
// SetFileCache 设置文件缓存。
//
// 为静态文件处理器启用文件缓存功能。
// 缓存可以显著提升小文件的访问性能。
//
// 参数:
// - fc: 文件缓存实例
//
// 注意事项:
// - 仅对小于 1MB 的文件启用缓存
// - 缓存会自动检测文件修改并更新
func (h *StaticHandler) SetFileCache(fc *cache.FileCache) {
h.mu.Lock()
defer h.mu.Unlock()
h.fileCache = fc
}
// SetGzipStatic 设置预压缩文件支持。
//
// 启用后,对于匹配扩展名的请求,优先发送预压缩文件。
//
// 参数:
// - enabled: 是否启用预压缩支持
// - extensions: 支持预压缩的源文件扩展名列表(如 [".html", ".css", ".js"]),为空使用默认值
// - precompressedExtensions: 预压缩文件扩展名列表(如 [".br", ".gz"]),为空使用默认值
//
// 使用示例:
//
// handler.SetGzipStatic(true, nil, []string{".gz", ".br"})
func (h *StaticHandler) SetGzipStatic(enabled bool, extensions, precompressedExtensions []string) {
h.mu.Lock()
defer h.mu.Unlock()
if enabled {
h.gzipStatic = compression.NewGzipStatic(true, h.root, extensions, precompressedExtensions)
}
}
// 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.mu.Lock()
defer h.mu.Unlock()
h.tryFiles = tryFiles
h.tryFilesPass = tryFilesPass
h.router = router
}
// SetSymlinkCheck 设置符号链接安全检查。
//
// 启用后,服务文件前会验证符号链接指向的文件是否在允许的根目录范围内。
// 防止通过符号链接访问敏感文件(如 /etc/passwd
//
// 参数:
// - enabled: 是否启用符号链接安全检查
func (h *StaticHandler) SetSymlinkCheck(enabled bool) {
h.mu.Lock()
defer h.mu.Unlock()
h.symlinkCheck = enabled
}
// SetInternal 设置内部访问限制。
//
// 启用后,仅允许内部重定向访问该静态位置。
// 外部直接请求将返回 404 Not Found。
//
// 参数:
// - enabled: 是否启用内部访问限制
func (h *StaticHandler) SetInternal(enabled bool) {
h.mu.Lock()
defer h.mu.Unlock()
h.internal = enabled
}
// SetExpires 设置缓存过期时间。
//
// 支持 nginx 兼容格式30d, 1h, 1m, max, epoch, off
// 设置后会在响应中添加 Cache-Control 和 Expires 头。
//
// 参数:
// - expires: 过期时间字符串
func (h *StaticHandler) SetExpires(expires string) {
h.mu.Lock()
defer h.mu.Unlock()
h.expires = expires
}
// SetAutoIndex 设置目录列表功能。
//
// 启用后,当请求目录且没有索引文件时,生成目录列表页面。
//
// 参数:
// - enabled: 是否启用
// - format: 输出格式html/json/xml
// - localtime: 使用本地时间
// - exactSize: 显示精确大小
func (h *StaticHandler) SetAutoIndex(enabled bool, format string, localtime, exactSize bool) {
h.mu.Lock()
defer h.mu.Unlock()
h.autoIndex = enabled
h.autoIndexFormat = format
h.autoIndexLocaltime = localtime
h.autoIndexExactSize = exactSize
}
// 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.mu.Lock()
defer h.mu.Unlock()
h.cacheTTL = ttl
if h.fileInfoCache != nil {
h.fileInfoCache.SetTTL(ttl)
}
}
// Handle 处理静态文件请求。
//
// 根据请求路径查找并返回对应的静态文件。
// 支持目录索引文件、try_files、缓存查找和零拷贝传输。
//
// 参数:
// - ctx: fasthttp 请求上下文
//
// 处理流程:
// 1. 安全检查:防止目录遍历攻击
// 2. 如果配置了 try_files按顺序尝试查找文件
// 3. 检查文件/目录是否存在
// 4. 如果是目录,尝试查找索引文件
// 5. 尝试发送预压缩文件
// 6. 尝试从缓存获取
// 7. 大文件使用零拷贝传输
// 8. 读取文件并存入缓存
func (h *StaticHandler) Handle(ctx *fasthttp.RequestCtx) {
h.mu.RLock()
defer h.mu.RUnlock()
reqPath := string(ctx.Path())
// 检查 internal 限制
if h.internal && !utils.IsInternalRedirect(ctx) {
utils.SendError(ctx, utils.ErrNotFound)
return
}
// 安全检查:防止目录遍历
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)
}
// tryServeFromFileCache 尝试从文件缓存直接响应。
// 命中缓存且文件未修改时直接写入响应并返回 true。
func (h *StaticHandler) tryServeFromFileCache(ctx *fasthttp.RequestCtx, filePath string, info os.FileInfo) bool {
if h.fileCache == nil {
return false
}
entry, ok := h.fileCache.Get(filePath)
if !ok {
return false
}
// TTL 验证cacheTTL > 0 时启用)
if h.cacheTTL > 0 && time.Since(entry.CachedAt) < h.cacheTTL {
if isNotModified(ctx, entry.ETag, info.ModTime()) {
ctx.Response.SetStatusCode(fasthttp.StatusNotModified)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
ctx.Response.SkipBody = true
return true
}
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(entry.ContentType)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return true
}
// TTL 过期或未启用 TTL验证文件新鲜度
if entry.ModTime.Equal(info.ModTime()) {
if isNotModified(ctx, entry.ETag, info.ModTime()) {
ctx.Response.SetStatusCode(fasthttp.StatusNotModified)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
ctx.Response.SkipBody = true
return true
}
if h.cacheTTL > 0 {
h.fileCache.RefreshCachedAt(filePath)
}
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(entry.ContentType)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return true
}
// 文件已修改,删除缓存
h.fileCache.Delete(filePath)
return false
}
// handleTryFiles 处理 try_files 逻辑。
//
// 按顺序尝试查找文件,支持 $uri 和 $uri/ 占位符。
//
// 占位符说明:
// - $uri: 请求路径对应的文件
// - $uri/: 请求路径对应的目录下的索引文件
//
// 参数:
// - ctx: fasthttp 请求上下文
// - reqPath: 原始请求路径
func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string) {
// 零分配路径剥离
relPath := h.stripPathPrefix(reqPath)
for _, tryFile := range h.tryFiles {
// 解析占位符
targetPath := h.resolveTryFilePath(tryFile, relPath)
// 构建完整文件路径
filePath := h.buildFilePath(targetPath)
// 检查文件/目录是否存在(带负缓存)
info, exists, _ := h.statWithCache(filePath)
if !exists {
continue // 不存在,尝试下一个
}
if info.IsDir() {
// 如果是目录,尝试查找索引文件
for _, idx := range h.index {
idxPath := filepath.Join(filePath, idx)
idxInfo, idxExists, _ := h.statWithCache(idxPath)
if !idxExists {
continue
}
if !idxInfo.IsDir() {
if h.tryServeFromFileCache(ctx, idxPath, idxInfo) {
return
}
h.serveFile(ctx, idxPath, idxInfo, false)
return
}
}
continue // 目录中没有索引文件,尝试下一个
}
// 找到文件,检查是否是内部重定向
if tryFile != "$uri" && !strings.HasPrefix(tryFile, "$uri") {
// 这是内部重定向fallback 文件)
h.handleInternalRedirect(ctx, targetPath)
return
}
// 直接服务文件
h.serveFile(ctx, filePath, info, false)
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默认直接服务文件不触发中间件
filePath := h.buildFilePath(targetPath)
info, exists, err := h.statWithCache(filePath)
if !exists {
if err != nil {
utils.SendError(ctx, utils.ErrNotFound)
} else {
utils.SendError(ctx, utils.ErrForbidden)
}
return
}
if info.IsDir() {
utils.SendError(ctx, utils.ErrForbidden)
return
}
h.serveFile(ctx, filePath, info, false)
}
// handleStandard 标准静态文件处理流程。
//
// 参数:
// - ctx: fasthttp 请求上下文
// - reqPath: 请求路径
func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string) {
// 零分配路径剥离
relPath := h.stripPathPrefix(reqPath)
// 构建完整文件路径
filePath := h.buildFilePath(relPath)
// 检查文件/目录是否存在(带负缓存)
info, exists, _ := h.statWithCache(filePath)
if !exists {
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)
idxInfo, idxExists, _ := h.statWithCache(idxPath)
if idxExists && !idxInfo.IsDir() {
if h.tryServeFromFileCache(ctx, idxPath, idxInfo) {
return
}
h.serveFile(ctx, idxPath, idxInfo, true)
return
}
}
// 尝试 autoindex
if h.autoIndex {
config := AutoIndexConfig{
Format: h.autoIndexFormat,
Localtime: h.autoIndexLocaltime,
ExactSize: h.autoIndexExactSize,
}
if GenerateAutoIndex(ctx, filePath, reqPath, config) {
return
}
}
utils.SendError(ctx, utils.ErrForbidden)
return
}
// Phase 2: 缓存查找 + TTL 验证,减少 os.ReadFile 调用
if h.tryServeFromFileCache(ctx, filePath, info) {
return
}
// Phase 3: 缓存未命中,调用 serveFile 处理
h.serveFile(ctx, filePath, info, true)
}
// serveFile 提供文件服务,支持缓存和零拷贝传输。
//
// 内部方法,负责实际的文件发送逻辑。
//
// 参数:
// - ctx: fasthttp 请求上下文
// - filePath: 文件绝对路径
// - info: 文件信息(用于判断文件大小和修改时间)
func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, info os.FileInfo, skipCacheLookup bool) {
// 生成 ETag 并检查条件请求(在预压缩检查之前)
etag := utils.GenerateETag(info.ModTime(), info.Size())
if isNotModified(ctx, etag, info.ModTime()) {
ctx.Response.SetStatusCode(fasthttp.StatusNotModified)
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
ctx.Response.SkipBody = true
return
}
// 尝试发送预压缩文件
if h.gzipStatic != nil {
relPath := strings.TrimPrefix(filePath, h.root)
if h.gzipStatic.ServeFile(ctx, relPath) {
// 预压缩文件已发送,补充验证头
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
return
}
}
// 尝试从缓存获取
if !skipCacheLookup && h.fileCache != nil {
if entry, ok := h.fileCache.Get(filePath); ok {
// 检查文件是否被修改
if entry.ModTime.Equal(info.ModTime()) {
// 缓存命中且文件未修改
// 使用缓存的 ETag 和 ContentType避免重新生成
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(entry.ContentType)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
return
}
// 文件已修改,删除旧缓存
h.fileCache.Delete(filePath)
}
}
// 大文件使用零拷贝传输
// 使用 fasthttp 的 SetBodyStream它会
// 1. 先写 HTTP 头到 bufio.Writer
// 2. Flush HTTP 头到 socket关键步骤
// 3. copyZeroAlloc → ReadFrom → sendfile
// 这样保证 HTTP 头先发送,避免顺序错乱导致的 "200 0" malformed response
if h.useSendfile && info.Size() >= MinSendfileSize {
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
file, err := os.Open(filePath)
if err == nil {
// SetBodyStream 会在 handler 返回后由 fasthttp 统一处理
// HTTP 头写入、Flush 和 sendfile 的顺序
ctx.Response.SetBodyStream(file, int(info.Size()))
return
}
}
// 读取文件内容
data, err := os.ReadFile(filePath)
if err != nil {
utils.SendError(ctx, utils.ErrInternalError)
return
}
// 存入缓存(仅对小文件缓存)
contentType := mimeutil.DetectContentType(filePath)
if h.fileCache != nil && info.Size() < 1024*1024 { // < 1MB
_ = h.fileCache.Set(filePath, data, info.Size(), info.ModTime(), contentType)
}
ctx.Response.SetBody(data)
ctx.Response.Header.SetContentType(contentType)
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
}
// setCacheHeaders 设置缓存控制响应头。
func (h *StaticHandler) setCacheHeaders(ctx *fasthttp.RequestCtx) {
if h.expires == "" || h.expires == expiresOff {
return
}
if h.expires == "epoch" {
ctx.Response.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
ctx.Response.Header.Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
return
}
if h.expires == expiresMax {
ctx.Response.Header.Set("Cache-Control", "public, max-age=315360000, immutable")
ctx.Response.Header.Set("Expires", time.Now().Add(315360000*time.Second).UTC().Format(httpTimeFormat))
return
}
maxAge := parseExpires(h.expires)
if maxAge > 0 {
ctx.Response.Header.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
ctx.Response.Header.Set("Expires", time.Now().Add(time.Duration(maxAge)*time.Second).UTC().Format(httpTimeFormat))
}
}
// parseExpires 解析 nginx 兼容的过期时间格式。
// 支持格式30d, 1h, 1m, 1s, 30d1h 等
// 返回秒数。
func parseExpires(expires string) int64 {
if expires == "" || expires == expiresOff {
return 0
}
if expires == expiresMax {
return 315360000
}
if expires == "epoch" {
return -1
}
var total int64
var num int64
for _, ch := range expires {
switch {
case ch >= '0' && ch <= '9':
num = num*10 + int64(ch-'0')
case ch == 'd':
total += num * 86400
num = 0
case ch == 'h':
total += num * 3600
num = 0
case ch == 'm':
total += num * 60
num = 0
case ch == 's':
total += num
num = 0
}
}
return total
}
// 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
}
// isNotModified 检查条件请求是否匹配(返回 true 表示应返回 304
func isNotModified(ctx *fasthttp.RequestCtx, etag string, modTime time.Time) bool {
if match := ctx.Request.Header.Peek("If-None-Match"); len(match) > 0 {
// RFC 9110: If-None-Match = #entity-tag逗号分隔
for tag := range strings.SplitSeq(string(match), ",") {
if strings.TrimSpace(tag) == etag {
return true
}
}
}
if since := ctx.Request.Header.Peek("If-Modified-Since"); len(since) > 0 {
if t, err := fasthttp.ParseHTTPDate(since); err == nil {
return !modTime.After(t)
}
}
return false
}