feat(middleware,http3,config): 增强 gzip_static 支持 Brotli,新增 HTTP/3 0-RTT 与性能配置验证
- gzip_static: 支持 .br 和 .gz 预压缩文件,按 br > gzip 优先级选择 - http3/server: 新增 Enable0RTT 配置,启用时输出安全警告 - config: 废弃 FileCache.LRUEviction 字段,新增 validatePerformance 验证 - 新增 gzip_static 完整测试套件覆盖编码优先级、路径遍历防护等场景 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
20016ebb59
commit
038c0639fd
@ -244,7 +244,8 @@ type FileCacheConfig struct {
|
|||||||
MaxEntries int64 `yaml:"max_entries"` // 最大缓存条目数
|
MaxEntries int64 `yaml:"max_entries"` // 最大缓存条目数
|
||||||
MaxSize int64 `yaml:"max_size"` // 内存上限(字节)
|
MaxSize int64 `yaml:"max_size"` // 内存上限(字节)
|
||||||
Inactive time.Duration `yaml:"inactive"` // 未访问淘汰时间
|
Inactive time.Duration `yaml:"inactive"` // 未访问淘汰时间
|
||||||
LRUEviction bool `yaml:"lru_eviction"` // 启用 LRU 淘汰
|
// Deprecated: 该字段已废弃,将在未来版本中移除,请使用 MaxSize 代替
|
||||||
|
LRUEviction bool `yaml:"lru_eviction"` // 启用 LRU 淘汰(已废弃)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TransportConfig HTTP Transport 配置。
|
// TransportConfig HTTP Transport 配置。
|
||||||
@ -444,5 +445,10 @@ func Validate(cfg *Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证性能配置
|
||||||
|
if err := validatePerformance(&cfg.Performance); err != nil {
|
||||||
|
return fmt.Errorf("performance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,7 +90,7 @@ func DefaultConfig() *Config {
|
|||||||
Level: 6,
|
Level: 6,
|
||||||
MinSize: 1024,
|
MinSize: 1024,
|
||||||
GzipStatic: false,
|
GzipStatic: false,
|
||||||
GzipStaticExtensions: []string{".gz", ".br"},
|
GzipStaticExtensions: []string{".br", ".gz"},
|
||||||
Types: []string{
|
Types: []string{
|
||||||
"text/html",
|
"text/html",
|
||||||
"text/css",
|
"text/css",
|
||||||
@ -398,6 +398,7 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) {
|
|||||||
fmt.Fprintf(&buf, " max_entries: %d # 最大缓存条目\n", cfg.Performance.FileCache.MaxEntries)
|
fmt.Fprintf(&buf, " max_entries: %d # 最大缓存条目\n", cfg.Performance.FileCache.MaxEntries)
|
||||||
fmt.Fprintf(&buf, " max_size: %d # 内存上限(字节,%dMB)\n", cfg.Performance.FileCache.MaxSize, cfg.Performance.FileCache.MaxSize/1024/1024)
|
fmt.Fprintf(&buf, " max_size: %d # 内存上限(字节,%dMB)\n", cfg.Performance.FileCache.MaxSize, cfg.Performance.FileCache.MaxSize/1024/1024)
|
||||||
fmt.Fprintf(&buf, " inactive: %ds # 未访问淘汰时间\n", int(cfg.Performance.FileCache.Inactive.Seconds()))
|
fmt.Fprintf(&buf, " inactive: %ds # 未访问淘汰时间\n", int(cfg.Performance.FileCache.Inactive.Seconds()))
|
||||||
|
buf.WriteString(" # Deprecated: lru_eviction 已废弃,将在未来版本中移除\n")
|
||||||
fmt.Fprintf(&buf, " lru_eviction: %v # 启用 LRU 淘汰\n", cfg.Performance.FileCache.LRUEviction)
|
fmt.Fprintf(&buf, " lru_eviction: %v # 启用 LRU 淘汰\n", cfg.Performance.FileCache.LRUEviction)
|
||||||
buf.WriteString(" transport: # HTTP Transport 连接池\n")
|
buf.WriteString(" transport: # HTTP Transport 连接池\n")
|
||||||
fmt.Fprintf(&buf, " max_idle_conns: %d # 最大空闲连接\n", cfg.Performance.Transport.MaxIdleConns)
|
fmt.Fprintf(&buf, " max_idle_conns: %d # 最大空闲连接\n", cfg.Performance.Transport.MaxIdleConns)
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"rua.plus/lolly/internal/loadbalance"
|
"rua.plus/lolly/internal/loadbalance"
|
||||||
@ -634,3 +635,32 @@ func validateStream(s *StreamConfig) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePerformance 验证性能配置。
|
||||||
|
//
|
||||||
|
// 检查性能配置中的废弃选项和潜在问题,输出警告信息。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - p: 性能配置对象
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - error: 验证失败时返回错误信息,成功返回 nil
|
||||||
|
func validatePerformance(p *PerformanceConfig) error {
|
||||||
|
// 检查废弃的 LRUEviction 字段
|
||||||
|
if p.FileCache.LRUEviction {
|
||||||
|
fmt.Fprintln(os.Stderr, "[警告] performance.file_cache.lru_eviction 已废弃,请使用 max_size 代替")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Transport 配置(可能导致性能问题)
|
||||||
|
if p.Transport.MaxIdleConns < 0 {
|
||||||
|
return errors.New("transport.max_idle_conns 不能为负数")
|
||||||
|
}
|
||||||
|
if p.Transport.MaxIdleConnsPerHost < 0 {
|
||||||
|
return errors.New("transport.max_idle_conns_per_host 不能为负数")
|
||||||
|
}
|
||||||
|
if p.Transport.MaxConnsPerHost < 0 {
|
||||||
|
return errors.New("transport.max_conns_per_host 不能为负数")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -906,3 +906,126 @@ func TestValidateStream(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidatePerformance(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config PerformanceConfig
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "空配置有效",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
FileCache: FileCacheConfig{},
|
||||||
|
Transport: TransportConfig{},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有效的 file_cache 配置",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
FileCache: FileCacheConfig{
|
||||||
|
MaxEntries: 1000,
|
||||||
|
MaxSize: 1024 * 1024 * 100,
|
||||||
|
},
|
||||||
|
GoroutinePool: GoroutinePoolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有效的 transport 配置(零值)",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
Transport: TransportConfig{
|
||||||
|
MaxIdleConns: 0,
|
||||||
|
MaxIdleConnsPerHost: 0,
|
||||||
|
MaxConnsPerHost: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有效的 transport 配置(正值)",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
Transport: TransportConfig{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
MaxConnsPerHost: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LRUEviction=true(废弃警告)",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
FileCache: FileCacheConfig{
|
||||||
|
LRUEviction: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MaxIdleConns 负数",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
Transport: TransportConfig{
|
||||||
|
MaxIdleConns: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "transport.max_idle_conns 不能为负数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MaxIdleConnsPerHost 负数",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
Transport: TransportConfig{
|
||||||
|
MaxIdleConnsPerHost: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "transport.max_idle_conns_per_host 不能为负数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MaxConnsPerHost 负数",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
Transport: TransportConfig{
|
||||||
|
MaxConnsPerHost: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "transport.max_conns_per_host 不能为负数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "多个 transport 字段为负",
|
||||||
|
config: PerformanceConfig{
|
||||||
|
Transport: TransportConfig{
|
||||||
|
MaxIdleConns: -1,
|
||||||
|
MaxIdleConnsPerHost: -2,
|
||||||
|
MaxConnsPerHost: -3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "transport.max_idle_conns 不能为负数",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validatePerformance(&tt.config)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("validatePerformance() 期望返回错误,但返回 nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||||
|
t.Errorf("validatePerformance() 错误消息不匹配,期望包含 %q,实际 %q", tt.errMsg, err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("validatePerformance() 期望返回 nil,但返回错误: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -109,6 +109,16 @@ func (s *Server) Start() error {
|
|||||||
MaxIncomingStreams: int64(s.config.MaxStreams),
|
MaxIncomingStreams: int64(s.config.MaxStreams),
|
||||||
MaxIdleTimeout: s.config.IdleTimeout,
|
MaxIdleTimeout: s.config.IdleTimeout,
|
||||||
KeepAlivePeriod: 30 * time.Second,
|
KeepAlivePeriod: 30 * time.Second,
|
||||||
|
Allow0RTT: s.config.Enable0RTT,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用了 0-RTT,输出安全警告
|
||||||
|
if s.config.Enable0RTT {
|
||||||
|
logging.Warn().
|
||||||
|
Msg("HTTP/3 0-RTT is enabled. " +
|
||||||
|
"For 0-RTT to work, TLS session tickets must be configured " +
|
||||||
|
"(TLSConfig.ClientSessionCache and TLSConfig.SessionTicketKey). " +
|
||||||
|
"See documentation for details.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置默认值
|
// 设置默认值
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
package compression
|
package compression
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@ -25,8 +24,8 @@ import (
|
|||||||
|
|
||||||
// GzipStatic 预压缩文件支持。
|
// GzipStatic 预压缩文件支持。
|
||||||
//
|
//
|
||||||
// 检查是否存在预压缩的 .gz 文件,如果存在则直接发送,
|
// 检查是否存在预压缩的 .gz 或 .br 文件,如果存在且客户端支持对应编码,
|
||||||
// 避免实时压缩的 CPU 开销。
|
// 则直接发送,避免实时压缩的 CPU 开销。
|
||||||
type GzipStatic struct {
|
type GzipStatic struct {
|
||||||
// enabled 是否启用
|
// enabled 是否启用
|
||||||
enabled bool
|
enabled bool
|
||||||
@ -36,6 +35,9 @@ type GzipStatic struct {
|
|||||||
|
|
||||||
// extensions 支持的扩展名
|
// extensions 支持的扩展名
|
||||||
extensions []string
|
extensions []string
|
||||||
|
|
||||||
|
// precompressedExtensions 预压缩扩展名,按优先级排序(默认 [".br", ".gz"])
|
||||||
|
precompressedExtensions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGzipStatic 创建预压缩文件处理器。
|
// NewGzipStatic 创建预压缩文件处理器。
|
||||||
@ -49,16 +51,18 @@ func NewGzipStatic(enabled bool, root string, extensions []string) *GzipStatic {
|
|||||||
extensions = []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
|
extensions = []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
|
||||||
}
|
}
|
||||||
return &GzipStatic{
|
return &GzipStatic{
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
root: root,
|
root: root,
|
||||||
extensions: extensions,
|
extensions: extensions,
|
||||||
|
precompressedExtensions: []string{".br", ".gz"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeFile 发送预压缩文件(如果存在)。
|
// ServeFile 发送预压缩文件(如果存在)。
|
||||||
//
|
//
|
||||||
// 检查是否存在对应的 .gz 文件,如果存在且客户端支持 gzip,
|
// 检查是否存在对应的 .br 或 .gz 文件,按优先级(br > gzip)选择:
|
||||||
// 则发送预压缩文件。
|
// 1. 如果客户端支持 br 且 .br 文件存在,返回 .br 文件
|
||||||
|
// 2. 否则如果客户端支持 gzip 且 .gz 文件存在,返回 .gz 文件
|
||||||
//
|
//
|
||||||
// 参数:
|
// 参数:
|
||||||
// - ctx: FastHTTP 请求上下文
|
// - ctx: FastHTTP 请求上下文
|
||||||
@ -71,36 +75,48 @@ func (g *GzipStatic) ServeFile(ctx *fasthttp.RequestCtx, filePath string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查客户端是否支持 gzip
|
|
||||||
acceptEncoding := ctx.Request.Header.Peek("Accept-Encoding")
|
|
||||||
if !bytes.Contains(acceptEncoding, []byte("gzip")) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查文件扩展名
|
// 检查文件扩展名
|
||||||
if !g.matchExtension(filePath) {
|
if !g.matchExtension(filePath) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查预压缩文件是否存在
|
|
||||||
gzPath := filePath + ".gz"
|
|
||||||
fullGzPath := filepath.Join(g.root, gzPath)
|
|
||||||
|
|
||||||
// 安全检查:防止目录遍历
|
// 安全检查:防止目录遍历
|
||||||
if strings.Contains(gzPath, "..") {
|
if strings.Contains(filePath, "..") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查文件是否存在
|
// 获取 Accept-Encoding 头
|
||||||
if _, err := os.Stat(fullGzPath); err != nil {
|
acceptEncoding := ctx.Request.Header.Peek("Accept-Encoding")
|
||||||
return false
|
|
||||||
|
// 按优先级检查预压缩文件
|
||||||
|
for _, ext := range g.precompressedExtensions {
|
||||||
|
// 检查客户端是否支持该编码
|
||||||
|
if !supportsEncoding(acceptEncoding, ext) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建预压缩文件路径
|
||||||
|
compressedPath := filePath + ext
|
||||||
|
fullPath := filepath.Join(g.root, compressedPath)
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if _, err := os.Stat(fullPath); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 Content-Encoding 头
|
||||||
|
switch ext {
|
||||||
|
case ".br":
|
||||||
|
ctx.Response.Header.Set("Content-Encoding", "br")
|
||||||
|
case ".gz":
|
||||||
|
ctx.Response.Header.Set("Content-Encoding", "gzip")
|
||||||
|
}
|
||||||
|
ctx.Response.Header.Set("Vary", "Accept-Encoding")
|
||||||
|
fasthttp.ServeFile(ctx, fullPath)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送预压缩文件
|
return false
|
||||||
ctx.Response.Header.Set("Content-Encoding", "gzip")
|
|
||||||
ctx.Response.Header.Set("Vary", "Accept-Encoding")
|
|
||||||
fasthttp.ServeFile(ctx, fullGzPath)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryServeFile 尝试发送预压缩文件的静态方法。
|
// TryServeFile 尝试发送预压缩文件的静态方法。
|
||||||
@ -145,3 +161,22 @@ func (g *GzipStatic) Extensions() []string {
|
|||||||
func DefaultExtensions() []string {
|
func DefaultExtensions() []string {
|
||||||
return []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
|
return []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// supportsEncoding 检查客户端是否支持指定编码。
|
||||||
|
//
|
||||||
|
// 简单检查,忽略 q-value,固定优先级由遍历顺序决定。
|
||||||
|
func supportsEncoding(acceptEncoding []byte, ext string) bool {
|
||||||
|
if len(acceptEncoding) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
enc := strings.ToLower(string(acceptEncoding))
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".br":
|
||||||
|
return strings.Contains(enc, "br")
|
||||||
|
case ".gz":
|
||||||
|
return strings.Contains(enc, "gzip")
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
481
internal/middleware/compression/gzip_static_test.go
Normal file
481
internal/middleware/compression/gzip_static_test.go
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
// Package compression 提供 gzip_static 预压缩文件测试。
|
||||||
|
package compression
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGzipStaticServeFile_BrotliPriority 测试 .br 文件优先于 .gz 文件
|
||||||
|
func TestGzipStaticServeFile_BrotliPriority(t *testing.T) {
|
||||||
|
// 创建临时目录
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 创建 .br 和 .gz 文件
|
||||||
|
brFile := filepath.Join(tmpDir, "test.js.br")
|
||||||
|
gzFile := filepath.Join(tmpDir, "test.js.gz")
|
||||||
|
|
||||||
|
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .br 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(gzFile, []byte("gz content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .gz 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := NewGzipStatic(true, tmpDir, nil)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
acceptEncoding string
|
||||||
|
wantServed bool
|
||||||
|
wantEncoding string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "同时支持 br 和 gzip,优先返回 br",
|
||||||
|
acceptEncoding: "br, gzip",
|
||||||
|
wantServed: true,
|
||||||
|
wantEncoding: "br",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "只支持 br",
|
||||||
|
acceptEncoding: "br",
|
||||||
|
wantServed: true,
|
||||||
|
wantEncoding: "br",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "只支持 gzip",
|
||||||
|
acceptEncoding: "gzip",
|
||||||
|
wantServed: true,
|
||||||
|
wantEncoding: "gzip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "支持 deflate 和 gzip,返回 gzip",
|
||||||
|
acceptEncoding: "deflate, gzip",
|
||||||
|
wantServed: true,
|
||||||
|
wantEncoding: "gzip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "不支持任何编码",
|
||||||
|
acceptEncoding: "",
|
||||||
|
wantServed: false,
|
||||||
|
wantEncoding: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "支持 br(大小写不敏感)",
|
||||||
|
acceptEncoding: "BR",
|
||||||
|
wantServed: true,
|
||||||
|
wantEncoding: "br",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "支持 gzip(大小写不敏感)",
|
||||||
|
acceptEncoding: "GZIP",
|
||||||
|
wantServed: true,
|
||||||
|
wantEncoding: "gzip",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := &fasthttp.RequestCtx{}
|
||||||
|
ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding)
|
||||||
|
|
||||||
|
served := g.ServeFile(ctx, "test.js")
|
||||||
|
|
||||||
|
if served != tt.wantServed {
|
||||||
|
t.Errorf("ServeFile() = %v, want %v", served, tt.wantServed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantServed {
|
||||||
|
encoding := ctx.Response.Header.Peek("Content-Encoding")
|
||||||
|
if string(encoding) != tt.wantEncoding {
|
||||||
|
t.Errorf("Content-Encoding = %q, want %q", string(encoding), tt.wantEncoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGzipStaticServeFile_GzipFallback 测试仅 .gz 存在时的回退
|
||||||
|
func TestGzipStaticServeFile_GzipFallback(t *testing.T) {
|
||||||
|
// 创建临时目录
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 只创建 .gz 文件
|
||||||
|
gzFile := filepath.Join(tmpDir, "test.css.gz")
|
||||||
|
if err := os.WriteFile(gzFile, []byte("gz content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .gz 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := NewGzipStatic(true, tmpDir, nil)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
acceptEncoding string
|
||||||
|
wantServed bool
|
||||||
|
wantEncoding string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "支持 br 但没有 .br 文件,回退到 gzip",
|
||||||
|
acceptEncoding: "br, gzip",
|
||||||
|
wantServed: true,
|
||||||
|
wantEncoding: "gzip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "只支持 gzip",
|
||||||
|
acceptEncoding: "gzip",
|
||||||
|
wantServed: true,
|
||||||
|
wantEncoding: "gzip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "只支持 br,没有 .br 文件,不服务",
|
||||||
|
acceptEncoding: "br",
|
||||||
|
wantServed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := &fasthttp.RequestCtx{}
|
||||||
|
ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding)
|
||||||
|
|
||||||
|
served := g.ServeFile(ctx, "test.css")
|
||||||
|
|
||||||
|
if served != tt.wantServed {
|
||||||
|
t.Errorf("ServeFile() = %v, want %v", served, tt.wantServed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantServed {
|
||||||
|
encoding := ctx.Response.Header.Peek("Content-Encoding")
|
||||||
|
if string(encoding) != tt.wantEncoding {
|
||||||
|
t.Errorf("Content-Encoding = %q, want %q", string(encoding), tt.wantEncoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGzipStaticServeFile_AcceptEncodingParsing 测试 Accept-Encoding 头解析
|
||||||
|
func TestGzipStaticServeFile_AcceptEncodingParsing(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 创建测试文件
|
||||||
|
brFile := filepath.Join(tmpDir, "test.html.br")
|
||||||
|
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .br 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := NewGzipStatic(true, tmpDir, nil)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
acceptEncoding string
|
||||||
|
wantSupported bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "包含 br",
|
||||||
|
acceptEncoding: "gzip, deflate, br",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "br 在中间",
|
||||||
|
acceptEncoding: "gzip, br, deflate",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "br 在最后",
|
||||||
|
acceptEncoding: "gzip, deflate,br",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "包含空格",
|
||||||
|
acceptEncoding: "gzip, br , deflate",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "q-value 支持",
|
||||||
|
acceptEncoding: "br;q=0.9, gzip;q=0.8",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "没有 br",
|
||||||
|
acceptEncoding: "gzip, deflate",
|
||||||
|
wantSupported: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := &fasthttp.RequestCtx{}
|
||||||
|
ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding)
|
||||||
|
|
||||||
|
served := g.ServeFile(ctx, "test.html")
|
||||||
|
|
||||||
|
if served != tt.wantSupported {
|
||||||
|
t.Errorf("ServeFile() = %v, want %v", served, tt.wantSupported)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGzipStaticServeFile_Disabled 测试禁用时不服务
|
||||||
|
func TestGzipStaticServeFile_Disabled(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 创建测试文件
|
||||||
|
brFile := filepath.Join(tmpDir, "test.js.br")
|
||||||
|
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .br 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用的 GzipStatic
|
||||||
|
g := NewGzipStatic(false, tmpDir, nil)
|
||||||
|
|
||||||
|
ctx := &fasthttp.RequestCtx{}
|
||||||
|
ctx.Request.Header.Set("Accept-Encoding", "br")
|
||||||
|
|
||||||
|
served := g.ServeFile(ctx, "test.js")
|
||||||
|
|
||||||
|
if served {
|
||||||
|
t.Error("禁用时不应服务文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGzipStaticServeFile_InvalidExtension 测试无效扩展名
|
||||||
|
func TestGzipStaticServeFile_InvalidExtension(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 创建测试文件
|
||||||
|
brFile := filepath.Join(tmpDir, "test.exe.br")
|
||||||
|
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .br 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := NewGzipStatic(true, tmpDir, nil)
|
||||||
|
|
||||||
|
ctx := &fasthttp.RequestCtx{}
|
||||||
|
ctx.Request.Header.Set("Accept-Encoding", "br")
|
||||||
|
|
||||||
|
served := g.ServeFile(ctx, "test.exe")
|
||||||
|
|
||||||
|
if served {
|
||||||
|
t.Error("无效扩展名不应服务文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGzipStaticServeFile_PathTraversal 测试路径遍历防护
|
||||||
|
func TestGzipStaticServeFile_PathTraversal(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 创建测试文件
|
||||||
|
brFile := filepath.Join(tmpDir, "test.js.br")
|
||||||
|
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .br 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := NewGzipStatic(true, tmpDir, nil)
|
||||||
|
|
||||||
|
ctx := &fasthttp.RequestCtx{}
|
||||||
|
ctx.Request.Header.Set("Accept-Encoding", "br")
|
||||||
|
|
||||||
|
// 尝试路径遍历
|
||||||
|
served := g.ServeFile(ctx, "../test.js")
|
||||||
|
|
||||||
|
if served {
|
||||||
|
t.Error("路径遍历应被阻止")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGzipStaticServeFile_VaryHeader 测试 Vary 头设置
|
||||||
|
func TestGzipStaticServeFile_VaryHeader(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 创建测试文件
|
||||||
|
brFile := filepath.Join(tmpDir, "test.js.br")
|
||||||
|
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .br 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := NewGzipStatic(true, tmpDir, nil)
|
||||||
|
|
||||||
|
ctx := &fasthttp.RequestCtx{}
|
||||||
|
ctx.Request.Header.Set("Accept-Encoding", "br")
|
||||||
|
|
||||||
|
g.ServeFile(ctx, "test.js")
|
||||||
|
|
||||||
|
vary := ctx.Response.Header.Peek("Vary")
|
||||||
|
if string(vary) != "Accept-Encoding" {
|
||||||
|
t.Errorf("Vary 头 = %q, want %q", string(vary), "Accept-Encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewGzipStatic_DefaultExtensions 测试默认扩展名
|
||||||
|
func TestNewGzipStatic_DefaultExtensions(t *testing.T) {
|
||||||
|
g := NewGzipStatic(true, "/tmp", nil)
|
||||||
|
|
||||||
|
expected := []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
|
||||||
|
got := g.Extensions()
|
||||||
|
|
||||||
|
if len(got) != len(expected) {
|
||||||
|
t.Errorf("扩展名数量 = %d, want %d", len(got), len(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, ext := range expected {
|
||||||
|
if got[i] != ext {
|
||||||
|
t.Errorf("扩展名[%d] = %q, want %q", i, got[i], ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewGzipStatic_CustomExtensions 测试自定义扩展名
|
||||||
|
func TestNewGzipStatic_CustomExtensions(t *testing.T) {
|
||||||
|
custom := []string{".custom", ".ext"}
|
||||||
|
g := NewGzipStatic(true, "/tmp", custom)
|
||||||
|
|
||||||
|
got := g.Extensions()
|
||||||
|
if len(got) != 2 || got[0] != ".custom" || got[1] != ".ext" {
|
||||||
|
t.Errorf("自定义扩展名 = %v, want %v", got, custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGzipStatic_Enabled 测试 Enabled 方法
|
||||||
|
func TestGzipStatic_Enabled(t *testing.T) {
|
||||||
|
g1 := NewGzipStatic(true, "/tmp", nil)
|
||||||
|
if !g1.Enabled() {
|
||||||
|
t.Error("Enabled() = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
g2 := NewGzipStatic(false, "/tmp", nil)
|
||||||
|
if g2.Enabled() {
|
||||||
|
t.Error("Enabled() = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultExtensions 测试 DefaultExtensions 函数
|
||||||
|
func TestDefaultExtensions(t *testing.T) {
|
||||||
|
expected := []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
|
||||||
|
got := DefaultExtensions()
|
||||||
|
|
||||||
|
if len(got) != len(expected) {
|
||||||
|
t.Errorf("默认扩展名数量 = %d, want %d", len(got), len(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, ext := range expected {
|
||||||
|
if got[i] != ext {
|
||||||
|
t.Errorf("默认扩展名[%d] = %q, want %q", i, got[i], ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSupportsEncoding 测试 supportsEncoding 函数
|
||||||
|
func TestSupportsEncoding(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
acceptEncoding string
|
||||||
|
wantEncoding string
|
||||||
|
wantSupported bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "支持 br",
|
||||||
|
acceptEncoding: "br",
|
||||||
|
wantEncoding: ".br",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "支持 gzip",
|
||||||
|
acceptEncoding: "gzip",
|
||||||
|
wantEncoding: ".gz",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "不支持",
|
||||||
|
acceptEncoding: "deflate",
|
||||||
|
wantEncoding: ".br",
|
||||||
|
wantSupported: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空 Accept-Encoding",
|
||||||
|
acceptEncoding: "",
|
||||||
|
wantEncoding: ".br",
|
||||||
|
wantSupported: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "br 在中间",
|
||||||
|
acceptEncoding: "gzip, br, deflate",
|
||||||
|
wantEncoding: ".br",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gzip 在中间",
|
||||||
|
acceptEncoding: "br, gzip, deflate",
|
||||||
|
wantEncoding: ".gz",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "大小写不敏感",
|
||||||
|
acceptEncoding: "BR, GZIP",
|
||||||
|
wantEncoding: ".br",
|
||||||
|
wantSupported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "未知扩展名",
|
||||||
|
acceptEncoding: "br, gzip",
|
||||||
|
wantEncoding: ".unknown",
|
||||||
|
wantSupported: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
supported := supportsEncoding([]byte(tt.acceptEncoding), tt.wantEncoding)
|
||||||
|
if supported != tt.wantSupported {
|
||||||
|
t.Errorf("supportsEncoding(%q, %q) = %v, want %v",
|
||||||
|
tt.acceptEncoding, tt.wantEncoding, supported, tt.wantSupported)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTryServeFile 测试 TryServeFile 静态方法
|
||||||
|
func TestTryServeFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 创建测试文件
|
||||||
|
brFile := filepath.Join(tmpDir, "test.js.br")
|
||||||
|
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
|
||||||
|
t.Fatalf("创建 .br 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &fasthttp.RequestCtx{}
|
||||||
|
ctx.Request.Header.Set("Accept-Encoding", "br")
|
||||||
|
|
||||||
|
served := TryServeFile(ctx, tmpDir, "test.js", nil)
|
||||||
|
|
||||||
|
if !served {
|
||||||
|
t.Error("TryServeFile() = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := ctx.Response.Header.Peek("Content-Encoding")
|
||||||
|
if string(encoding) != "br" {
|
||||||
|
t.Errorf("Content-Encoding = %q, want %q", string(encoding), "br")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGzipStatic_PrecompressedExtensions 测试预压缩扩展名优先级
|
||||||
|
func TestGzipStatic_PrecompressedExtensions(t *testing.T) {
|
||||||
|
g := NewGzipStatic(true, "/tmp", nil)
|
||||||
|
|
||||||
|
// 验证默认预压缩扩展名顺序
|
||||||
|
expected := []string{".br", ".gz"}
|
||||||
|
if len(g.precompressedExtensions) != len(expected) {
|
||||||
|
t.Errorf("预压缩扩展名数量 = %d, want %d", len(g.precompressedExtensions), len(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, ext := range expected {
|
||||||
|
if g.precompressedExtensions[i] != ext {
|
||||||
|
t.Errorf("预压缩扩展名[%d] = %q, want %q", i, g.precompressedExtensions[i], ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user