feat(config,handler,server): 支持多静态目录配置,新增路径前缀匹配

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-07 16:26:55 +08:00
parent f2352ab9cc
commit 83e1fe38ba
10 changed files with 193 additions and 326 deletions

View File

@ -1,230 +0,0 @@
# Lolly 配置文件
# 服务器配置(单服务器模式)
server:
listen: ":8080" # 监听地址
name: "localhost" # 服务器名称(虚拟主机匹配)
read_timeout: 30s # 读取超时0 表示不限制)
write_timeout: 30s # 写入超时0 表示不限制)
idle_timeout: 120s # 空闲超时0 表示不限制)
max_conns_per_ip: 1000 # 每 IP 最大连接数0 表示不限制)
max_requests_per_conn: 10000 # 每连接最大请求数0 表示不限制)
# 静态文件服务配置
static:
root: "/var/www/html" # 静态文件根目录
index: # 索引文件
- "index.html"
- "index.htm"
# 反向代理配置
# proxy:
# - path: /api # 匹配路径前缀
# targets: # 后端目标列表
# - url: http://backend1:8080
# weight: 3 # 权重(加权轮询时有效)
# - url: http://backend2:8080
# weight: 1
# load_balance: round_robin # 负载均衡算法(有效值: round_robin, weighted_round_robin, least_conn, ip_hash, consistent_hash
# hash_key: ip # 一致性哈希键(仅 load_balance=consistent_hash 时有效,有效值: ip, uri, header:X-Name
# virtual_nodes: 150 # 一致性哈希虚拟节点数(仅 load_balance=consistent_hash 时有效)
# health_check: # 健康检查
# interval: 10s
# path: /health
# timeout: 5s
# timeout: # 超时配置
# connect: 5s # 连接超时
# read: 30s # 读取超时
# write: 30s # 写入超时
# headers: # 头部修改
# set_request: {X-Custom: value}
# set_response: {X-Server: lolly}
# remove: [X-Powered-By]
# cache: # 代理缓存
# enabled: false
# max_age: 60s
# cache_lock: true # 防止缓存击穿
# stale_while_revalidate: 30s
# SSL/TLS 配置
# ssl:
# cert: /path/to/cert.pem # 证书文件
# key: /path/to/key.pem # 私钥文件
# cert_chain: /path/to/chain.pem # 证书链文件
# protocols: # TLS 版本(有效值: TLSv1.2, TLSv1.3
# - "TLSv1.2"
# - "TLSv1.3"
# ciphers: # 加密套件(仅 TLS 1.2 有效TLS 1.3 使用内置套件)
# - ECDHE-ECDSA-AES256-GCM-SHA384
# - ECDHE-RSA-AES256-GCM-SHA384
# - ECDHE-ECDSA-CHACHA20-POLY1305
# - ECDHE-RSA-CHACHA20-POLY1305
# # 拒绝不安全套件:含 RC4、DES、3DES、CBC 的配置将报错
# ocsp_stapling: false # OCSP Stapling
# hsts: # HTTP Strict Transport Security
# max_age: 31536000 # 过期时间(秒)
# include_sub_domains: true # 包含子域名
# preload: false # 加入 HSTS 预加载列表
# 安全配置
security:
# IP 访问控制
access:
allow: [] # 允许的 IP/CIDR 列表
deny: [] # 拒绝的 IP/CIDR 列表
default: "allow" # 默认动作(有效值: allow, deny
trusted_proxies: [] # 可信代理 CIDR 列表,用于 X-Forwarded-For 解析
# 速率限制
rate_limit:
request_rate: 0 # 每秒请求数0 表示不限制)
burst: 0 # 突发上限
conn_limit: 0 # 连接数限制
key: "ip" # 限流 key 来源(有效值: ip, header
algorithm: "token_bucket" # 限流算法(有效值: token_bucket, sliding_window
sliding_window_mode: "approximate" # 滑动窗口模式(有效值: approximate, precise仅 algorithm=sliding_window 时有效)
sliding_window: 60 # 滑动窗口大小(秒,仅 algorithm=sliding_window 时有效)
# 认证配置type 为空时禁用)
auth:
type: "" # 认证类型(有效值: basic空表示禁用
require_tls: true # 启用时强制 HTTPS
algorithm: "bcrypt" # 密码哈希算法(有效值: bcrypt, argon2id
users: [] # 用户列表
realm: "Restricted Area" # 认证域
min_password_length: 8 # 密码最小长度
# 安全头部
headers:
x_frame_options: "DENY" # 防止点击劫持(有效值: DENY, SAMEORIGIN, 空表示禁用)
x_content_type_options: "nosniff" # 防止 MIME 嗅探有效值nosniff空表示禁用
referrer_policy: "strict-origin-when-cross-origin" # 引用策略(有效值: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
# content_security_policy: "default-src 'self'" # 内容安全策略 CSP
# permissions_policy: "geolocation=(), microphone=()" # 权限策略
# URL 重写规则
# rewrite:
# - pattern: "^/old/(.*)$" # 匹配模式(正则表达式)
# replacement: /new/$1 # 替换目标
# flag: last # 标志(有效值: last, redirect, permanent, break
# 响应压缩配置
compression:
type: "gzip" # 压缩类型(有效值: gzip, brotli, both空表示禁用
level: 6 # 压缩级别(范围 0-90=不压缩1=最快9=最高压缩率)
min_size: 1024 # 最小压缩大小(字节,小于此值不压缩)
gzip_static: false # 启用预压缩文件支持(自动查找 .gz/.br 文件)
gzip_static_extensions: # 预压缩文件扩展名
- ".br"
- ".gz"
types: # 可压缩的 MIME 类型
- "text/html"
- "text/css"
- "text/javascript"
- "application/json"
- "application/javascript"
# 多虚拟主机模式(可选,每个虚拟主机支持完整的 server 配置)
# servers:
# - listen: ":8080" # 监听地址
# name: "api.example.com" # 服务器名称(用于虚拟主机匹配)
# read_timeout: 30s # 读取超时0 表示不限制)
# write_timeout: 30s # 写入超时0 表示不限制)
# idle_timeout: 120s # 空闲超时0 表示不限制)
# max_conns_per_ip: 1000 # 每 IP 最大连接数0 表示不限制)
# max_requests_per_conn: 10000 # 每连接最大请求数0 表示不限制)
# static: # 静态文件配置
# root: /var/www/api
# index: [index.html]
# proxy: # 反向代理配置
# - path: /api
# targets:
# - url: http://backend:8080
# load_balance: round_robin
# ssl: # SSL/TLS 配置
# cert: /path/to/api.cert.pem
# key: /path/to/api.key.pem
# protocols: [TLSv1.2, TLSv1.3]
# hsts:
# max_age: 31536000
# include_sub_domains: true
# security: # 安全配置
# access:
# default: allow
# rate_limit:
# request_rate: 100
# headers:
# x_frame_options: DENY
# compression: # 响应压缩配置
# type: gzip
# level: 6
# - listen: ":8443" # 另一个虚拟主机
# name: "static.example.com"
# static:
# root: /var/www/static
# index: [index.html, index.htm]
# ssl:
# cert: /path/to/static.cert.pem
# key: /path/to/static.key.pem
# compression:
# type: gzip
# SSL/TLS 默认配置说明(未配置证书时不启用)
# 默认 TLS 协议: TLSv1.2, TLSv1.3(不支持 TLSv1.0/1.1
# 默认 HSTS 配置: max_age=315360001年, include_sub_domains=true
# TCP/UDP Stream 代理配置(可选)
# stream:
# - listen: "3306" # 监听地址
# protocol: "tcp" # 协议类型(有效值: tcp, udp
# upstream:
# targets: # 上游目标列表
# - addr: "mysql1:3306" # 目标地址
# weight: 3 # 权重(加权轮询时有效)
# - addr: "mysql2:3306"
# weight: 1
# load_balance: "round_robin" # 负载均衡算法(有效值: round_robin, weighted_round_robin, least_conn, ip_hash
# 日志配置
logging:
format: "text" # 全局日志格式(有效值: text, json控制启动/停止日志格式
access:
path: "" # 日志文件路径(空表示输出到 stdout
format: '$remote_addr - $remote_user [$time] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"' # 访问日志格式,近似 nginx combined
# 支持变量: $remote_addr, $remote_user, $request, $status, $body_bytes_sent, $request_time, $http_referer, $http_user_agent, $time
# 特殊值 "json" 输出结构化 JSON
error:
path: "" # 日志文件路径(空表示输出到 stderr
level: "info" # 日志级别(有效值: debug, info, warn, error级别越高日志越少
# 性能配置
performance:
goroutine_pool: # Goroutine 池(处理并发请求)
enabled: false # 是否启用
max_workers: 1000 # 最大 worker 数
min_workers: 10 # 最小 worker 数(预热)
idle_timeout: 60s # 空闲超时
file_cache: # 静态文件缓存
max_entries: 10000 # 最大缓存条目
max_size: 268435456 # 内存上限字节256MB
inactive: 20s # 未访问淘汰时间
transport: # HTTP Transport 连接池
max_idle_conns: 100 # 最大空闲连接
max_idle_conns_per_host: 32 # 每主机空闲连接
idle_conn_timeout: 90s # 空闲超时
max_conns_per_host: 0 # 每主机最大连接0 表示不限制)
# HTTP/3 (QUIC) 配置(需要 SSL 证书)
http3:
enabled: false # 是否启用 HTTP/3
listen: ":443" # UDP 监听地址
max_streams: 100 # 最大并发流
idle_timeout: 60s # 空闲超时
enable_0rtt: false # 启用 0-RTT早期数据可能存在安全风险
# 监控配置
monitoring:
status:
path: "/_status" # 状态端点路径
allow: # 允许访问的 IP
- "127.0.0.1"

View File

@ -145,9 +145,9 @@ type ServerConfig struct {
// 多个服务器可通过 Name 区分不同域名或服务 // 多个服务器可通过 Name 区分不同域名或服务
Name string `yaml:"name"` Name string `yaml:"name"`
// Static 静态文件服务配置 // Static 静态文件服务配置列表
// 用于提供静态资源,如 HTML、CSS、JS、图片等 // 支持多个静态目录,按 path 前缀匹配
Static StaticConfig `yaml:"static"` Static []StaticConfig `yaml:"static"`
// Proxy 反向代理规则列表 // Proxy 反向代理规则列表
// 按顺序匹配,首个匹配的规则生效 // 按顺序匹配,首个匹配的规则生效
@ -192,9 +192,10 @@ type ServerConfig struct {
// StaticConfig 静态文件服务配置。 // StaticConfig 静态文件服务配置。
// //
// 用于配置静态文件服务器的行为,包括根目录和索引文件。 // 用于配置静态文件服务器的行为,包括路径匹配、根目录和索引文件。
// //
// 注意事项: // 注意事项:
// - Path 为路径前缀,匹配的请求将被该静态处理器处理
// - Root 路径可以是相对路径或绝对路径 // - Root 路径可以是相对路径或绝对路径
// - 索引文件按顺序查找,第一个存在的文件将被使用 // - 索引文件按顺序查找,第一个存在的文件将被使用
// - 目录路径需要确保有读取权限 // - 目录路径需要确保有读取权限
@ -202,9 +203,17 @@ type ServerConfig struct {
// 使用示例: // 使用示例:
// //
// static: // static:
// root: "/var/www/html" // - path: "/"
// index: ["index.html", "index.htm", "default.html"] // root: "/var/www/html"
// index: ["index.html", "index.htm"]
// - path: "/assets/"
// root: "/var/www/assets"
type StaticConfig struct { type StaticConfig struct {
// Path 匹配路径前缀
// 以此前缀开头的请求将被该静态处理器处理
// 默认为 "/",匹配所有路径
Path string `yaml:"path"`
// Root 静态文件根目录 // Root 静态文件根目录
// 所有静态文件请求都将以此目录为基础解析 // 所有静态文件请求都将以此目录为基础解析
Root string `yaml:"root"` Root string `yaml:"root"`

View File

@ -15,9 +15,10 @@ func TestLoad(t *testing.T) {
server: server:
listen: ":8080" listen: ":8080"
static: static:
root: "/var/www" - path: "/"
index: root: "/var/www"
- "index.html" index:
- "index.html"
logging: logging:
access: access:
path: "/var/log/access.log" path: "/var/log/access.log"
@ -49,11 +50,11 @@ monitoring:
if cfg.Server.Listen != ":8080" { if cfg.Server.Listen != ":8080" {
t.Errorf("Server.Listen = %q, want %q", cfg.Server.Listen, ":8080") t.Errorf("Server.Listen = %q, want %q", cfg.Server.Listen, ":8080")
} }
if cfg.Server.Static.Root != "/var/www" { if cfg.Server.Static[0].Root != "/var/www" {
t.Errorf("Server.Static.Root = %q, want %q", cfg.Server.Static.Root, "/var/www") t.Errorf("Server.Static.Root = %q, want %q", cfg.Server.Static[0].Root, "/var/www")
} }
if len(cfg.Server.Static.Index) != 1 || cfg.Server.Static.Index[0] != "index.html" { if len(cfg.Server.Static[0].Index) != 1 || cfg.Server.Static[0].Index[0] != "index.html" {
t.Errorf("Server.Static.Index = %v, want [index.html]", cfg.Server.Static.Index) t.Errorf("Server.Static.Index = %v, want [index.html]", cfg.Server.Static[0].Index)
} }
}) })
@ -139,7 +140,8 @@ func TestLoadFromString(t *testing.T) {
server: server:
listen: ":9090" listen: ":9090"
static: static:
root: "/app/public" - path: "/"
root: "/app/public"
` `
cfg, err := LoadFromString(yamlStr) cfg, err := LoadFromString(yamlStr)
if err != nil { if err != nil {
@ -149,8 +151,8 @@ server:
if cfg.Server.Listen != ":9090" { if cfg.Server.Listen != ":9090" {
t.Errorf("Server.Listen = %q, want %q", cfg.Server.Listen, ":9090") t.Errorf("Server.Listen = %q, want %q", cfg.Server.Listen, ":9090")
} }
if cfg.Server.Static.Root != "/app/public" { if cfg.Server.Static[0].Root != "/app/public" {
t.Errorf("Server.Static.Root = %q, want %q", cfg.Server.Static.Root, "/app/public") t.Errorf("Server.Static.Root = %q, want %q", cfg.Server.Static[0].Root, "/app/public")
} }
}) })
@ -192,10 +194,11 @@ func TestSave(t *testing.T) {
cfg := &Config{ cfg := &Config{
Server: ServerConfig{ Server: ServerConfig{
Listen: ":8080", Listen: ":8080",
Static: StaticConfig{ Static: []StaticConfig{{
Path: "/",
Root: "/var/www", Root: "/var/www",
Index: []string{"index.html"}, Index: []string{"index.html"},
}, }},
}, },
} }
@ -215,8 +218,8 @@ func TestSave(t *testing.T) {
if loaded.Server.Listen != cfg.Server.Listen { if loaded.Server.Listen != cfg.Server.Listen {
t.Errorf("loaded.Server.Listen = %q, want %q", loaded.Server.Listen, cfg.Server.Listen) t.Errorf("loaded.Server.Listen = %q, want %q", loaded.Server.Listen, cfg.Server.Listen)
} }
if loaded.Server.Static.Root != cfg.Server.Static.Root { if loaded.Server.Static[0].Root != cfg.Server.Static[0].Root {
t.Errorf("loaded.Server.Static.Root = %q, want %q", loaded.Server.Static.Root, cfg.Server.Static.Root) t.Errorf("loaded.Server.Static[0].Root = %q, want %q", loaded.Server.Static[0].Root, cfg.Server.Static[0].Root)
} }
}) })
@ -263,10 +266,11 @@ func TestSave(t *testing.T) {
Server: ServerConfig{ Server: ServerConfig{
Listen: ":8443", Listen: ":8443",
Name: "default", Name: "default",
Static: StaticConfig{ Static: []StaticConfig{{
Path: "/",
Root: "/var/www/html", Root: "/var/www/html",
Index: []string{"index.html", "index.htm"}, Index: []string{"index.html", "index.htm"},
}, }},
Proxy: []ProxyConfig{ Proxy: []ProxyConfig{
{ {
Path: "/api", Path: "/api",

View File

@ -45,10 +45,11 @@ func DefaultConfig() *Config {
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
MaxConnsPerIP: 1000, MaxConnsPerIP: 1000,
MaxRequestsPerConn: 10000, MaxRequestsPerConn: 10000,
Static: StaticConfig{ Static: []StaticConfig{{
Path: "/",
Root: "/var/www/html", Root: "/var/www/html",
Index: []string{"index.html", "index.htm"}, Index: []string{"index.html", "index.htm"},
}, }},
SSL: SSLConfig{ SSL: SSLConfig{
Protocols: []string{"TLSv1.2", "TLSv1.3"}, Protocols: []string{"TLSv1.2", "TLSv1.3"},
OCSPStapling: false, OCSPStapling: false,
@ -183,13 +184,20 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) {
buf.WriteString("\n") buf.WriteString("\n")
// static 配置 // static 配置
buf.WriteString(" # 静态文件服务配置\n") buf.WriteString(" # 静态文件服务配置(支持多个目录)\n")
buf.WriteString(" static:\n") buf.WriteString(" static:\n")
fmt.Fprintf(&buf, " root: \"%s\" # 静态文件根目录\n", cfg.Server.Static.Root) for _, st := range cfg.Server.Static {
buf.WriteString(" index: # 索引文件\n") buf.WriteString(" - path: \"/\" # 匹配路径前缀\n")
for _, idx := range cfg.Server.Static.Index { fmt.Fprintf(&buf, " root: \"%s\" # 静态文件根目录\n", st.Root)
fmt.Fprintf(&buf, " - \"%s\"\n", idx) buf.WriteString(" index: # 索引文件\n")
for _, idx := range st.Index {
fmt.Fprintf(&buf, " - \"%s\"\n", idx)
}
} }
buf.WriteString(" # 示例:额外的静态目录\n")
buf.WriteString(" # - path: \"/assets/\"\n")
buf.WriteString(" # root: \"/var/www/assets\"\n")
buf.WriteString(" # index: [\"index.html\"]\n")
buf.WriteString("\n") buf.WriteString("\n")
// proxy 配置示例 // proxy 配置示例
@ -319,9 +327,10 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) {
buf.WriteString("# idle_timeout: 120s # 空闲超时0 表示不限制)\n") buf.WriteString("# idle_timeout: 120s # 空闲超时0 表示不限制)\n")
buf.WriteString("# max_conns_per_ip: 1000 # 每 IP 最大连接数0 表示不限制)\n") buf.WriteString("# max_conns_per_ip: 1000 # 每 IP 最大连接数0 表示不限制)\n")
buf.WriteString("# max_requests_per_conn: 10000 # 每连接最大请求数0 表示不限制)\n") buf.WriteString("# max_requests_per_conn: 10000 # 每连接最大请求数0 表示不限制)\n")
buf.WriteString("# static: # 静态文件配置\n") buf.WriteString("# static: # 静态文件配置(支持多个目录)\n")
buf.WriteString("# root: /var/www/api\n") buf.WriteString("# - path: /\n")
buf.WriteString("# index: [index.html]\n") buf.WriteString("# root: /var/www/api\n")
buf.WriteString("# index: [index.html]\n")
buf.WriteString("# proxy: # 反向代理配置\n") buf.WriteString("# proxy: # 反向代理配置\n")
buf.WriteString("# - path: /api\n") buf.WriteString("# - path: /api\n")
buf.WriteString("# targets:\n") buf.WriteString("# targets:\n")
@ -347,8 +356,9 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) {
buf.WriteString("# - listen: \":8443\" # 另一个虚拟主机\n") buf.WriteString("# - listen: \":8443\" # 另一个虚拟主机\n")
buf.WriteString("# name: \"static.example.com\"\n") buf.WriteString("# name: \"static.example.com\"\n")
buf.WriteString("# static:\n") buf.WriteString("# static:\n")
buf.WriteString("# root: /var/www/static\n") buf.WriteString("# - path: /\n")
buf.WriteString("# index: [index.html, index.htm]\n") buf.WriteString("# root: /var/www/static\n")
buf.WriteString("# index: [index.html, index.htm]\n")
buf.WriteString("# ssl:\n") buf.WriteString("# ssl:\n")
buf.WriteString("# cert: /path/to/static.cert.pem\n") buf.WriteString("# cert: /path/to/static.cert.pem\n")
buf.WriteString("# key: /path/to/static.key.pem\n") buf.WriteString("# key: /path/to/static.key.pem\n")

View File

@ -56,7 +56,7 @@ func validateServer(s *ServerConfig, isDefault bool) error {
} }
// 验证静态文件配置 // 验证静态文件配置
if err := validateStatic(&s.Static); err != nil { if err := validateStatics(s.Static); err != nil {
return fmt.Errorf("static: %w", err) return fmt.Errorf("static: %w", err)
} }
@ -67,6 +67,11 @@ func validateServer(s *ServerConfig, isDefault bool) error {
} }
} }
// 检查 static 和 proxy 路径冲突
if err := validatePathConflicts(s); err != nil {
return err
}
// 验证重写规则 // 验证重写规则
for i := range s.Rewrite { for i := range s.Rewrite {
if err := validateRewrite(&s.Rewrite[i]); err != nil { if err := validateRewrite(&s.Rewrite[i]); err != nil {
@ -92,6 +97,69 @@ func validateServer(s *ServerConfig, isDefault bool) error {
return nil return nil
} }
// validateStatics 验证静态文件配置数组。
//
// 检查静态文件配置的路径重复和根目录路径安全性。
//
// 参数:
// - statics: 静态文件配置数组
//
// 返回值:
// - error: 验证失败时返回错误信息,成功返回 nil
func validateStatics(statics []StaticConfig) error {
if len(statics) == 0 {
return nil
}
paths := make(map[string]int)
for i, s := range statics {
// Path 默认为 "/"
path := s.Path
if path == "" {
path = "/"
}
// 检查路径重复
if idx, exists := paths[path]; exists {
return fmt.Errorf("路径 %s 重复定义 (static[%d] 和 static[%d])", path, idx, i)
}
paths[path] = i
// 验证根目录路径安全
if s.Root != "" && strings.Contains(s.Root, "..") {
return fmt.Errorf("static[%d]: 根目录路径不能包含 '..'", i)
}
}
return nil
}
// validatePathConflicts 检查 static 和 proxy 路径冲突。
//
// 确保 static 和 proxy 没有相同的 path 前缀。
//
// 参数:
// - s: 服务器配置对象
//
// 返回值:
// - error: 发现冲突时返回错误信息,成功返回 nil
func validatePathConflicts(s *ServerConfig) error {
staticPaths := make(map[string]int)
for i, st := range s.Static {
path := st.Path
if path == "" {
path = "/"
}
staticPaths[path] = i
}
for i, p := range s.Proxy {
if idx, exists := staticPaths[p.Path]; exists {
return fmt.Errorf("路径 %s 同时定义在 static[%d] 和 proxy[%d]", p.Path, idx, i)
}
}
return nil
}
// validateStatic 验证静态文件配置。 // validateStatic 验证静态文件配置。
// //
// 检查静态文件根目录路径的安全性,防止路径遍历攻击。 // 检查静态文件根目录路径的安全性,防止路径遍历攻击。

View File

@ -32,7 +32,7 @@ func TestValidateServer(t *testing.T) {
name: "有效配置", name: "有效配置",
config: ServerConfig{ config: ServerConfig{
Listen: ":8080", Listen: ":8080",
Static: StaticConfig{Root: "/var/www"}, Static: []StaticConfig{{Path: "/", Root: "/var/www"}},
Proxy: []ProxyConfig{ Proxy: []ProxyConfig{
{Path: "/api", Targets: []ProxyTarget{{URL: "http://backend:8080"}}}, {Path: "/api", Targets: []ProxyTarget{{URL: "http://backend:8080"}}},
}, },
@ -43,7 +43,7 @@ func TestValidateServer(t *testing.T) {
{ {
name: "默认服务器可省略Listen", name: "默认服务器可省略Listen",
config: ServerConfig{ config: ServerConfig{
Static: StaticConfig{Root: "/var/www"}, Static: []StaticConfig{{Path: "/", Root: "/var/www"}},
}, },
isDefault: true, isDefault: true,
wantErr: false, wantErr: false,
@ -51,7 +51,7 @@ func TestValidateServer(t *testing.T) {
{ {
name: "非默认服务器Listen缺失", name: "非默认服务器Listen缺失",
config: ServerConfig{ config: ServerConfig{
Static: StaticConfig{Root: "/var/www"}, Static: []StaticConfig{{Path: "/", Root: "/var/www"}},
}, },
isDefault: false, isDefault: false,
wantErr: true, wantErr: true,
@ -70,7 +70,7 @@ func TestValidateServer(t *testing.T) {
name: "静态根目录含..", name: "静态根目录含..",
config: ServerConfig{ config: ServerConfig{
Listen: ":8080", Listen: ":8080",
Static: StaticConfig{Root: "/var/../www"}, Static: []StaticConfig{{Path: "/", Root: "/var/../www"}},
}, },
isDefault: false, isDefault: false,
wantErr: true, wantErr: true,

View File

@ -41,6 +41,9 @@ type StaticHandler struct {
// root 静态文件根目录 // root 静态文件根目录
root string root string
// pathPrefix 路径前缀,会被剥离后拼接 root
pathPrefix string
// index 索引文件列表,当请求目录时依次查找 // index 索引文件列表,当请求目录时依次查找
index []string index []string
@ -60,6 +63,7 @@ type StaticHandler struct {
// //
// 参数: // 参数:
// - root: 静态文件根目录路径 // - root: 静态文件根目录路径
// - pathPrefix: 路径前缀,会被剥离后拼接 root
// - index: 索引文件列表,当请求目录时依次查找(如 ["index.html", "index.htm"] // - index: 索引文件列表,当请求目录时依次查找(如 ["index.html", "index.htm"]
// - useSendfile: 是否启用零拷贝传输(大文件优化) // - useSendfile: 是否启用零拷贝传输(大文件优化)
// //
@ -68,10 +72,11 @@ type StaticHandler struct {
// //
// 使用示例: // 使用示例:
// //
// handler := handler.NewStaticHandler("/var/www", []string{"index.html"}, true) // handler := handler.NewStaticHandler("/var/www", "/", []string{"index.html"}, true)
func NewStaticHandler(root string, index []string, useSendfile bool) *StaticHandler { func NewStaticHandler(root, pathPrefix string, index []string, useSendfile bool) *StaticHandler {
return &StaticHandler{ return &StaticHandler{
root: root, root: root,
pathPrefix: pathPrefix,
index: index, index: index,
useSendfile: useSendfile, useSendfile: useSendfile,
} }
@ -126,16 +131,24 @@ func (h *StaticHandler) SetGzipStatic(enabled bool, extensions []string) {
// 6. 大文件使用零拷贝传输 // 6. 大文件使用零拷贝传输
// 7. 读取文件并存入缓存 // 7. 读取文件并存入缓存
func (h *StaticHandler) Handle(ctx *fasthttp.RequestCtx) { func (h *StaticHandler) Handle(ctx *fasthttp.RequestCtx) {
path := string(ctx.Path()) reqPath := string(ctx.Path())
// 安全检查:防止目录遍历 // 安全检查:防止目录遍历
if strings.Contains(path, "..") { if strings.Contains(reqPath, "..") {
ctx.Error("Forbidden", fasthttp.StatusForbidden) ctx.Error("Forbidden", fasthttp.StatusForbidden)
return return
} }
// 剥离路径前缀
if h.pathPrefix != "" && h.pathPrefix != "/" {
reqPath = strings.TrimPrefix(reqPath, h.pathPrefix)
if !strings.HasPrefix(reqPath, "/") {
reqPath = "/" + reqPath
}
}
// 拼接文件路径 // 拼接文件路径
filePath := filepath.Join(h.root, path) filePath := filepath.Join(h.root, reqPath)
// 检查文件/目录是否存在 // 检查文件/目录是否存在
info, err := os.Stat(filePath) info, err := os.Stat(filePath)

View File

@ -28,7 +28,7 @@ import (
// newTestHandler 创建测试用的静态文件处理器 // newTestHandler 创建测试用的静态文件处理器
func newTestHandler(t *testing.T, root string) *StaticHandler { func newTestHandler(t *testing.T, root string) *StaticHandler {
t.Helper() t.Helper()
return NewStaticHandler(root, []string{"index.html", "index.htm"}, false) // 测试时禁用 sendfile return NewStaticHandler(root, "/", []string{"index.html", "index.htm"}, false) // 测试时禁用 sendfile
} }
// newTestContext 创建测试用的 fasthttp 请求上下文 // newTestContext 创建测试用的 fasthttp 请求上下文
@ -391,7 +391,7 @@ func TestNewStaticHandler(t *testing.T) {
t.Run("正常创建", func(t *testing.T) { t.Run("正常创建", func(t *testing.T) {
root := "/var/www" root := "/var/www"
index := []string{"index.html", "index.htm"} index := []string{"index.html", "index.htm"}
handler := NewStaticHandler(root, index, true) handler := NewStaticHandler(root, "/", index, true)
if handler == nil { if handler == nil {
t.Fatal("NewStaticHandler() 返回 nil") t.Fatal("NewStaticHandler() 返回 nil")
@ -405,7 +405,7 @@ func TestNewStaticHandler(t *testing.T) {
}) })
t.Run("空索引列表", func(t *testing.T) { t.Run("空索引列表", func(t *testing.T) {
handler := NewStaticHandler("/var/www", nil, false) handler := NewStaticHandler("/var/www", "/", nil, false)
if handler == nil { if handler == nil {
t.Fatal("NewStaticHandler() 返回 nil") t.Fatal("NewStaticHandler() 返回 nil")
} }
@ -417,7 +417,7 @@ func TestNewStaticHandler(t *testing.T) {
// TestStaticHandler_SetFileCache 测试设置文件缓存 // TestStaticHandler_SetFileCache 测试设置文件缓存
func TestStaticHandler_SetFileCache(t *testing.T) { func TestStaticHandler_SetFileCache(t *testing.T) {
handler := NewStaticHandler("/var/www", nil, false) handler := NewStaticHandler("/var/www", "/", nil, false)
// 设置 nil 缓存 // 设置 nil 缓存
handler.SetFileCache(nil) handler.SetFileCache(nil)
@ -431,7 +431,7 @@ func TestStaticHandler_SetFileCache(t *testing.T) {
// TestStaticHandler_SetGzipStatic 测试设置 Gzip 静态文件 // TestStaticHandler_SetGzipStatic 测试设置 Gzip 静态文件
func TestStaticHandler_SetGzipStatic(t *testing.T) { func TestStaticHandler_SetGzipStatic(t *testing.T) {
handler := NewStaticHandler("/var/www", nil, false) handler := NewStaticHandler("/var/www", "/", nil, false)
// 启用 gzip // 启用 gzip
handler.SetGzipStatic(true, []string{".gz", ".gzip"}) handler.SetGzipStatic(true, []string{".gz", ".gzip"})
@ -509,7 +509,7 @@ func TestStaticHandler_Handle_Precompressed(t *testing.T) {
t.Fatalf("创建 gzip 文件失败: %v", err) t.Fatalf("创建 gzip 文件失败: %v", err)
} }
handler := NewStaticHandler(tmpDir, nil, false) handler := NewStaticHandler(tmpDir, "/", nil, false)
handler.SetGzipStatic(true, []string{".gz"}) handler.SetGzipStatic(true, []string{".gz"})
ctx := &fasthttp.RequestCtx{} ctx := &fasthttp.RequestCtx{}
@ -540,7 +540,7 @@ func TestStaticHandler_Handle_LargeFile(t *testing.T) {
} }
// 使用 sendfile 启用的处理器 // 使用 sendfile 启用的处理器
handler := NewStaticHandler(tmpDir, []string{"index.html"}, true) handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, true)
ctx := &fasthttp.RequestCtx{} ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/large.bin") ctx.Request.SetRequestURI("/large.bin")

View File

@ -24,6 +24,7 @@ import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net" "net"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -341,7 +342,7 @@ func (s *Server) startSingleMode() error {
s.registerProxyRoutes(router, &s.config.Server) s.registerProxyRoutes(router, &s.config.Server)
// 静态文件服务 // 静态文件服务
s.registerStaticHandler(router, &s.config.Server) s.registerStaticHandlers(router, &s.config.Server)
// 构建中间件链 // 构建中间件链
chain, err := s.buildMiddlewareChain(&s.config.Server) chain, err := s.buildMiddlewareChain(&s.config.Server)
@ -410,20 +411,7 @@ func (s *Server) startVHostMode() error {
s.registerProxyRoutes(router, &s.config.Servers[i]) s.registerProxyRoutes(router, &s.config.Servers[i])
// 静态文件 // 静态文件
staticHandler := handler.NewStaticHandler( s.registerStaticHandlers(router, &s.config.Servers[i])
s.config.Servers[i].Static.Root,
s.config.Servers[i].Static.Index,
true, // useSendfile
)
if s.fileCache != nil {
staticHandler.SetFileCache(s.fileCache)
}
// 设置预压缩文件支持
if s.config.Servers[i].Compression.GzipStatic {
staticHandler.SetGzipStatic(true, s.config.Servers[i].Compression.GzipStaticExtensions)
}
router.GET("/{filepath:*}", staticHandler.Handle)
router.HEAD("/{filepath:*}", staticHandler.Handle)
// 为每个虚拟主机构建独立的中间件链 // 为每个虚拟主机构建独立的中间件链
chain, err := s.buildMiddlewareChain(&s.config.Servers[i]) chain, err := s.buildMiddlewareChain(&s.config.Servers[i])
@ -454,19 +442,9 @@ func (s *Server) startVHostMode() error {
} }
s.registerProxyRoutes(router, &s.config.Server) s.registerProxyRoutes(router, &s.config.Server)
staticHandler := handler.NewStaticHandler(
s.config.Server.Static.Root, // 静态文件
s.config.Server.Static.Index, s.registerStaticHandlers(router, &s.config.Server)
true, // useSendfile
)
if s.fileCache != nil {
staticHandler.SetFileCache(s.fileCache)
}
// 设置预压缩文件支持
if s.config.Server.Compression.GzipStatic {
staticHandler.SetGzipStatic(true, s.config.Server.Compression.GzipStaticExtensions)
}
router.GET("/{filepath:*}", staticHandler.Handle)
chain, err := s.buildMiddlewareChain(&s.config.Server) chain, err := s.buildMiddlewareChain(&s.config.Server)
if err != nil { if err != nil {
@ -678,25 +656,39 @@ func (s *Server) getProxyCacheStats() ProxyCacheStats {
return total return total
} }
// registerStaticHandler 注册静态文件处理器。 // registerStaticHandlers 注册静态文件处理器。
// //
// 为路由器注册静态文件服务,支持文件缓存和预压缩文件。 // 为路由器注册静态文件服务,支持多个静态目录、文件缓存和预压缩文件。
// //
// 参数: // 参数:
// - router: 路由器实例,用于注册路由规则 // - router: 路由器实例,用于注册路由规则
// - cfg: 服务器配置,包含静态文件和压缩设置 // - cfg: 服务器配置,包含静态文件和压缩设置
func (s *Server) registerStaticHandler(router *handler.Router, cfg *config.ServerConfig) { func (s *Server) registerStaticHandlers(router *handler.Router, cfg *config.ServerConfig) {
staticHandler := handler.NewStaticHandler( for _, static := range cfg.Static {
cfg.Static.Root, path := static.Path
cfg.Static.Index, if path == "" {
true, // useSendfile path = "/"
) }
if s.fileCache != nil {
staticHandler.SetFileCache(s.fileCache) staticHandler := handler.NewStaticHandler(
static.Root,
path,
static.Index,
true, // useSendfile
)
if s.fileCache != nil {
staticHandler.SetFileCache(s.fileCache)
}
if cfg.Compression.GzipStatic {
staticHandler.SetGzipStatic(true, cfg.Compression.GzipStaticExtensions)
}
// 注册路由:确保路径以 / 结尾
routePath := path
if !strings.HasSuffix(routePath, "/") {
routePath += "/"
}
router.GET(routePath+"{filepath:*}", staticHandler.Handle)
router.HEAD(routePath+"{filepath:*}", staticHandler.Handle)
} }
if cfg.Compression.GzipStatic {
staticHandler.SetGzipStatic(true, cfg.Compression.GzipStaticExtensions)
}
router.GET("/{filepath:*}", staticHandler.Handle)
router.HEAD("/{filepath:*}", staticHandler.Handle)
} }

View File

@ -27,10 +27,11 @@ func TestNew(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Server: config.ServerConfig{ Server: config.ServerConfig{
Listen: ":8080", Listen: ":8080",
Static: config.StaticConfig{ Static: []config.StaticConfig{{
Path: "/",
Root: "./static", Root: "./static",
Index: []string{"index.html"}, Index: []string{"index.html"},
}, }},
}, },
} }