feat(handler): 静态文件支持 ETag 和 304 Not Modified

添加 generateETag 和 isNotModified 函数,在所有响应路径设置
ETag/Last-Modified 头,支持 If-None-Match 和 If-Modified-Since
条件请求返回 304,减少不必要的文件传输。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-24 13:13:31 +08:00
parent 308529d568
commit 65cdab60f9

View File

@ -19,6 +19,7 @@
package handler
import (
"fmt"
"os"
"path/filepath"
"strings"
@ -31,6 +32,8 @@ import (
"rua.plus/lolly/internal/utils"
)
const httpTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
// StaticHandler 静态文件处理器。
//
// 提供静态文件服务,支持目录索引、文件缓存和零拷贝传输。
@ -324,7 +327,7 @@ func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string)
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)
h.serveFile(ctx, idxPath, idxInfo, false)
return
}
}
@ -339,7 +342,7 @@ func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string)
}
// 直接服务文件
h.serveFile(ctx, filePath, info)
h.serveFile(ctx, filePath, info, false)
return
}
@ -430,7 +433,7 @@ func (h *StaticHandler) handleInternalRedirect(ctx *fasthttp.RequestCtx, targetP
utils.SendError(ctx, utils.ErrForbidden)
return
}
h.serveFile(ctx, filePath, info)
h.serveFile(ctx, filePath, info, false)
}
// handleStandard 标准静态文件处理流程。
@ -490,7 +493,7 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
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)
h.serveFile(ctx, idxPath, idxInfo, true)
return
}
}
@ -501,6 +504,14 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
// Phase 2: 缓存查找 + TTL 验证
// 在 serveFile 调用前检查缓存,减少 os.ReadFile 调用
// 注意: CachedAt 迁移已在 FileCache.Get() 内部完成,确保并发安全
etag := 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))
ctx.Response.SkipBody = true
return
}
if h.fileCache != nil {
if entry, ok := h.fileCache.Get(filePath); ok {
// TTL 验证cacheTTL > 0 时启用)
@ -508,6 +519,8 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
// TTL 内直接返回(无需验证 ModTime
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return
}
@ -519,6 +532,8 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
}
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return
}
@ -528,7 +543,7 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
}
// Phase 3: 缓存未命中,调用 serveFile 处理
h.serveFile(ctx, filePath, info)
h.serveFile(ctx, filePath, info, true)
}
// serveFile 提供文件服务,支持缓存和零拷贝传输。
@ -539,23 +554,38 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
// - ctx: fasthttp 请求上下文
// - filePath: 文件绝对路径
// - info: 文件信息(用于判断文件大小和修改时间)
func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, info os.FileInfo) {
func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, info os.FileInfo, skipCacheLookup bool) {
// 生成 ETag 并检查条件请求(在预压缩检查之前)
etag := 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))
ctx.Response.SkipBody = true
return
}
// 尝试发送预压缩文件
if h.gzipStatic != nil {
relPath := strings.TrimPrefix(filePath, h.root)
if h.gzipStatic.ServeFile(ctx, relPath) {
return // 预压缩文件已发送
// 预压缩文件已发送,补充验证头
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return
}
}
// 尝试从缓存获取
if h.fileCache != nil {
if !skipCacheLookup && 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))
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return
}
// 文件已修改,删除旧缓存
@ -571,6 +601,8 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
// 这样保证 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))
file, err := os.Open(filePath)
if err == nil {
@ -595,6 +627,8 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
ctx.Response.SetBody(data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
}
// validateSymlink 验证符号链接是否安全。
@ -656,3 +690,26 @@ func (h *StaticHandler) validateSymlink(filePath string) error {
return nil
}
// generateETag 基于 ModTime 和 Size 生成 ETag。
func generateETag(modTime time.Time, size int64) string {
return fmt.Sprintf("\"%x-%x\"", modTime.Unix(), size)
}
// 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
}