lolly/internal/handler/static.go
xfy 83e1fe38ba feat(config,handler,server): 支持多静态目录配置,新增路径前缀匹配
- Static 配置从单对象改为数组,支持多个静态目录
- StaticConfig 新增 Path 字段用于路径前缀匹配
- 添加 validateStatics 和 validatePathConflicts 验证函数
- 删除 config.example.yaml 示例文件(配置可通过 --generate 生成)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-07 16:26:55 +08:00

236 lines
6.5 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 (
"mime"
"os"
"path/filepath"
"strings"
"github.com/valyala/fasthttp"
"rua.plus/lolly/internal/cache"
"rua.plus/lolly/internal/middleware/compression"
)
// StaticHandler 静态文件处理器。
//
// 提供静态文件服务,支持目录索引、文件缓存和零拷贝传输。
//
// 注意事项:
// - 自动处理目录遍历攻击防护(拒绝包含 ".." 的路径)
// - 并发安全,可在多个 goroutine 中使用
// - 大文件(>= 8KB自动启用零拷贝传输
type StaticHandler struct {
// root 静态文件根目录
root string
// pathPrefix 路径前缀,会被剥离后拼接 root
pathPrefix string
// index 索引文件列表,当请求目录时依次查找
index []string
// useSendfile 是否启用零拷贝传输(大文件优化)
useSendfile bool
// fileCache 文件缓存实例(可选)
fileCache *cache.FileCache
// gzipStatic 预压缩文件支持(可选)
gzipStatic *compression.GzipStatic
}
// 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,
}
}
// 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)
}
}
// Handle 处理静态文件请求。
//
// 根据请求路径查找并返回对应的静态文件。
// 支持目录索引文件、缓存查找和零拷贝传输。
//
// 参数:
// - ctx: fasthttp 请求上下文
//
// 处理流程:
// 1. 安全检查:防止目录遍历攻击
// 2. 检查文件/目录是否存在
// 3. 如果是目录,尝试查找索引文件
// 4. 尝试发送预压缩文件
// 5. 尝试从缓存获取
// 6. 大文件使用零拷贝传输
// 7. 读取文件并存入缓存
func (h *StaticHandler) Handle(ctx *fasthttp.RequestCtx) {
reqPath := string(ctx.Path())
// 安全检查:防止目录遍历
if strings.Contains(reqPath, "..") {
ctx.Error("Forbidden", fasthttp.StatusForbidden)
return
}
// 剥离路径前缀
if h.pathPrefix != "" && h.pathPrefix != "/" {
reqPath = strings.TrimPrefix(reqPath, h.pathPrefix)
if !strings.HasPrefix(reqPath, "/") {
reqPath = "/" + reqPath
}
}
// 拼接文件路径
filePath := filepath.Join(h.root, reqPath)
// 检查文件/目录是否存在
info, err := os.Stat(filePath)
if err != nil {
ctx.Error("Not Found", fasthttp.StatusNotFound)
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
}
}
ctx.Error("Forbidden", fasthttp.StatusForbidden)
return
}
// 直接返回文件
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(mime.TypeByExtension(filepath.Ext(filePath)))
return
}
// 文件已修改,删除旧缓存
h.fileCache.Delete(filePath)
}
}
// 大文件使用零拷贝传输
if h.useSendfile && info.Size() >= MinSendfileSize {
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 {
ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError)
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(mime.TypeByExtension(filepath.Ext(filePath)))
}