diff --git a/config.example.yaml b/config.example.yaml deleted file mode 100644 index 3e325ad..0000000 --- a/config.example.yaml +++ /dev/null @@ -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-9,0=不压缩,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=31536000(1年), 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" diff --git a/internal/config/config.go b/internal/config/config.go index 5b6ebe3..a7819d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -145,9 +145,9 @@ type ServerConfig struct { // 多个服务器可通过 Name 区分不同域名或服务 Name string `yaml:"name"` - // Static 静态文件服务配置 - // 用于提供静态资源,如 HTML、CSS、JS、图片等 - Static StaticConfig `yaml:"static"` + // Static 静态文件服务配置列表 + // 支持多个静态目录,按 path 前缀匹配 + Static []StaticConfig `yaml:"static"` // Proxy 反向代理规则列表 // 按顺序匹配,首个匹配的规则生效 @@ -192,9 +192,10 @@ type ServerConfig struct { // StaticConfig 静态文件服务配置。 // -// 用于配置静态文件服务器的行为,包括根目录和索引文件。 +// 用于配置静态文件服务器的行为,包括路径匹配、根目录和索引文件。 // // 注意事项: +// - Path 为路径前缀,匹配的请求将被该静态处理器处理 // - Root 路径可以是相对路径或绝对路径 // - 索引文件按顺序查找,第一个存在的文件将被使用 // - 目录路径需要确保有读取权限 @@ -202,9 +203,17 @@ type ServerConfig struct { // 使用示例: // // static: -// root: "/var/www/html" -// index: ["index.html", "index.htm", "default.html"] +// - path: "/" +// root: "/var/www/html" +// index: ["index.html", "index.htm"] +// - path: "/assets/" +// root: "/var/www/assets" type StaticConfig struct { + // Path 匹配路径前缀 + // 以此前缀开头的请求将被该静态处理器处理 + // 默认为 "/",匹配所有路径 + Path string `yaml:"path"` + // Root 静态文件根目录 // 所有静态文件请求都将以此目录为基础解析 Root string `yaml:"root"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b878357..6773d21 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,9 +15,10 @@ func TestLoad(t *testing.T) { server: listen: ":8080" static: - root: "/var/www" - index: - - "index.html" + - path: "/" + root: "/var/www" + index: + - "index.html" logging: access: path: "/var/log/access.log" @@ -49,11 +50,11 @@ monitoring: if cfg.Server.Listen != ":8080" { t.Errorf("Server.Listen = %q, want %q", cfg.Server.Listen, ":8080") } - if cfg.Server.Static.Root != "/var/www" { - t.Errorf("Server.Static.Root = %q, want %q", 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[0].Root, "/var/www") } - if len(cfg.Server.Static.Index) != 1 || cfg.Server.Static.Index[0] != "index.html" { - t.Errorf("Server.Static.Index = %v, want [index.html]", cfg.Server.Static.Index) + 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[0].Index) } }) @@ -139,7 +140,8 @@ func TestLoadFromString(t *testing.T) { server: listen: ":9090" static: - root: "/app/public" + - path: "/" + root: "/app/public" ` cfg, err := LoadFromString(yamlStr) if err != nil { @@ -149,8 +151,8 @@ server: if cfg.Server.Listen != ":9090" { t.Errorf("Server.Listen = %q, want %q", cfg.Server.Listen, ":9090") } - if cfg.Server.Static.Root != "/app/public" { - t.Errorf("Server.Static.Root = %q, want %q", 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[0].Root, "/app/public") } }) @@ -192,10 +194,11 @@ func TestSave(t *testing.T) { cfg := &Config{ Server: ServerConfig{ Listen: ":8080", - Static: StaticConfig{ + Static: []StaticConfig{{ + Path: "/", Root: "/var/www", Index: []string{"index.html"}, - }, + }}, }, } @@ -215,8 +218,8 @@ func TestSave(t *testing.T) { if 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 { - t.Errorf("loaded.Server.Static.Root = %q, want %q", loaded.Server.Static.Root, cfg.Server.Static.Root) + if loaded.Server.Static[0].Root != cfg.Server.Static[0].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{ Listen: ":8443", Name: "default", - Static: StaticConfig{ + Static: []StaticConfig{{ + Path: "/", Root: "/var/www/html", Index: []string{"index.html", "index.htm"}, - }, + }}, Proxy: []ProxyConfig{ { Path: "/api", diff --git a/internal/config/defaults.go b/internal/config/defaults.go index f25c051..1cda69b 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -45,10 +45,11 @@ func DefaultConfig() *Config { IdleTimeout: 120 * time.Second, MaxConnsPerIP: 1000, MaxRequestsPerConn: 10000, - Static: StaticConfig{ + Static: []StaticConfig{{ + Path: "/", Root: "/var/www/html", Index: []string{"index.html", "index.htm"}, - }, + }}, SSL: SSLConfig{ Protocols: []string{"TLSv1.2", "TLSv1.3"}, OCSPStapling: false, @@ -183,13 +184,20 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) { buf.WriteString("\n") // static 配置 - buf.WriteString(" # 静态文件服务配置\n") + buf.WriteString(" # 静态文件服务配置(支持多个目录)\n") buf.WriteString(" static:\n") - fmt.Fprintf(&buf, " root: \"%s\" # 静态文件根目录\n", cfg.Server.Static.Root) - buf.WriteString(" index: # 索引文件\n") - for _, idx := range cfg.Server.Static.Index { - fmt.Fprintf(&buf, " - \"%s\"\n", idx) + for _, st := range cfg.Server.Static { + buf.WriteString(" - path: \"/\" # 匹配路径前缀\n") + fmt.Fprintf(&buf, " root: \"%s\" # 静态文件根目录\n", st.Root) + 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") // proxy 配置示例 @@ -319,9 +327,10 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) { buf.WriteString("# idle_timeout: 120s # 空闲超时(0 表示不限制)\n") buf.WriteString("# max_conns_per_ip: 1000 # 每 IP 最大连接数(0 表示不限制)\n") buf.WriteString("# max_requests_per_conn: 10000 # 每连接最大请求数(0 表示不限制)\n") - buf.WriteString("# static: # 静态文件配置\n") - buf.WriteString("# root: /var/www/api\n") - buf.WriteString("# index: [index.html]\n") + buf.WriteString("# static: # 静态文件配置(支持多个目录)\n") + buf.WriteString("# - path: /\n") + buf.WriteString("# root: /var/www/api\n") + buf.WriteString("# index: [index.html]\n") buf.WriteString("# proxy: # 反向代理配置\n") buf.WriteString("# - path: /api\n") buf.WriteString("# targets:\n") @@ -347,8 +356,9 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) { buf.WriteString("# - listen: \":8443\" # 另一个虚拟主机\n") buf.WriteString("# name: \"static.example.com\"\n") buf.WriteString("# static:\n") - buf.WriteString("# root: /var/www/static\n") - buf.WriteString("# index: [index.html, index.htm]\n") + buf.WriteString("# - path: /\n") + buf.WriteString("# root: /var/www/static\n") + buf.WriteString("# index: [index.html, index.htm]\n") buf.WriteString("# ssl:\n") buf.WriteString("# cert: /path/to/static.cert.pem\n") buf.WriteString("# key: /path/to/static.key.pem\n") diff --git a/internal/config/validate.go b/internal/config/validate.go index 17cbac3..83fc324 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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) } @@ -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 { if err := validateRewrite(&s.Rewrite[i]); err != nil { @@ -92,6 +97,69 @@ func validateServer(s *ServerConfig, isDefault bool) error { 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 验证静态文件配置。 // // 检查静态文件根目录路径的安全性,防止路径遍历攻击。 diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 6d98cfa..153d051 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -32,7 +32,7 @@ func TestValidateServer(t *testing.T) { name: "有效配置", config: ServerConfig{ Listen: ":8080", - Static: StaticConfig{Root: "/var/www"}, + Static: []StaticConfig{{Path: "/", Root: "/var/www"}}, Proxy: []ProxyConfig{ {Path: "/api", Targets: []ProxyTarget{{URL: "http://backend:8080"}}}, }, @@ -43,7 +43,7 @@ func TestValidateServer(t *testing.T) { { name: "默认服务器可省略Listen", config: ServerConfig{ - Static: StaticConfig{Root: "/var/www"}, + Static: []StaticConfig{{Path: "/", Root: "/var/www"}}, }, isDefault: true, wantErr: false, @@ -51,7 +51,7 @@ func TestValidateServer(t *testing.T) { { name: "非默认服务器Listen缺失", config: ServerConfig{ - Static: StaticConfig{Root: "/var/www"}, + Static: []StaticConfig{{Path: "/", Root: "/var/www"}}, }, isDefault: false, wantErr: true, @@ -70,7 +70,7 @@ func TestValidateServer(t *testing.T) { name: "静态根目录含..", config: ServerConfig{ Listen: ":8080", - Static: StaticConfig{Root: "/var/../www"}, + Static: []StaticConfig{{Path: "/", Root: "/var/../www"}}, }, isDefault: false, wantErr: true, diff --git a/internal/handler/static.go b/internal/handler/static.go index 34fd721..c6dc2f8 100644 --- a/internal/handler/static.go +++ b/internal/handler/static.go @@ -41,6 +41,9 @@ type StaticHandler struct { // root 静态文件根目录 root string + // pathPrefix 路径前缀,会被剥离后拼接 root + pathPrefix string + // index 索引文件列表,当请求目录时依次查找 index []string @@ -60,6 +63,7 @@ type StaticHandler struct { // // 参数: // - root: 静态文件根目录路径 +// - pathPrefix: 路径前缀,会被剥离后拼接 root // - index: 索引文件列表,当请求目录时依次查找(如 ["index.html", "index.htm"]) // - useSendfile: 是否启用零拷贝传输(大文件优化) // @@ -68,10 +72,11 @@ type StaticHandler struct { // // 使用示例: // -// handler := handler.NewStaticHandler("/var/www", []string{"index.html"}, true) -func NewStaticHandler(root string, index []string, useSendfile bool) *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, } @@ -126,16 +131,24 @@ func (h *StaticHandler) SetGzipStatic(enabled bool, extensions []string) { // 6. 大文件使用零拷贝传输 // 7. 读取文件并存入缓存 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) 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) diff --git a/internal/handler/static_test.go b/internal/handler/static_test.go index 9b94417..9ce4748 100644 --- a/internal/handler/static_test.go +++ b/internal/handler/static_test.go @@ -28,7 +28,7 @@ import ( // newTestHandler 创建测试用的静态文件处理器 func newTestHandler(t *testing.T, root string) *StaticHandler { t.Helper() - return NewStaticHandler(root, []string{"index.html", "index.htm"}, false) // 测试时禁用 sendfile + return NewStaticHandler(root, "/", []string{"index.html", "index.htm"}, false) // 测试时禁用 sendfile } // newTestContext 创建测试用的 fasthttp 请求上下文 @@ -391,7 +391,7 @@ func TestNewStaticHandler(t *testing.T) { t.Run("正常创建", func(t *testing.T) { root := "/var/www" index := []string{"index.html", "index.htm"} - handler := NewStaticHandler(root, index, true) + handler := NewStaticHandler(root, "/", index, true) if handler == nil { t.Fatal("NewStaticHandler() 返回 nil") @@ -405,7 +405,7 @@ func TestNewStaticHandler(t *testing.T) { }) t.Run("空索引列表", func(t *testing.T) { - handler := NewStaticHandler("/var/www", nil, false) + handler := NewStaticHandler("/var/www", "/", nil, false) if handler == nil { t.Fatal("NewStaticHandler() 返回 nil") } @@ -417,7 +417,7 @@ func TestNewStaticHandler(t *testing.T) { // TestStaticHandler_SetFileCache 测试设置文件缓存 func TestStaticHandler_SetFileCache(t *testing.T) { - handler := NewStaticHandler("/var/www", nil, false) + handler := NewStaticHandler("/var/www", "/", nil, false) // 设置 nil 缓存 handler.SetFileCache(nil) @@ -431,7 +431,7 @@ func TestStaticHandler_SetFileCache(t *testing.T) { // TestStaticHandler_SetGzipStatic 测试设置 Gzip 静态文件 func TestStaticHandler_SetGzipStatic(t *testing.T) { - handler := NewStaticHandler("/var/www", nil, false) + handler := NewStaticHandler("/var/www", "/", nil, false) // 启用 gzip handler.SetGzipStatic(true, []string{".gz", ".gzip"}) @@ -509,7 +509,7 @@ func TestStaticHandler_Handle_Precompressed(t *testing.T) { t.Fatalf("创建 gzip 文件失败: %v", err) } - handler := NewStaticHandler(tmpDir, nil, false) + handler := NewStaticHandler(tmpDir, "/", nil, false) handler.SetGzipStatic(true, []string{".gz"}) ctx := &fasthttp.RequestCtx{} @@ -540,7 +540,7 @@ func TestStaticHandler_Handle_LargeFile(t *testing.T) { } // 使用 sendfile 启用的处理器 - handler := NewStaticHandler(tmpDir, []string{"index.html"}, true) + handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, true) ctx := &fasthttp.RequestCtx{} ctx.Request.SetRequestURI("/large.bin") diff --git a/internal/server/server.go b/internal/server/server.go index 7beb581..697f6f9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,6 +24,7 @@ import ( "crypto/tls" "fmt" "net" + "strings" "sync/atomic" "time" @@ -341,7 +342,7 @@ func (s *Server) startSingleMode() error { 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) @@ -410,20 +411,7 @@ func (s *Server) startVHostMode() error { s.registerProxyRoutes(router, &s.config.Servers[i]) // 静态文件 - staticHandler := handler.NewStaticHandler( - 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) + s.registerStaticHandlers(router, &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) - staticHandler := handler.NewStaticHandler( - s.config.Server.Static.Root, - s.config.Server.Static.Index, - 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) + + // 静态文件 + s.registerStaticHandlers(router, &s.config.Server) chain, err := s.buildMiddlewareChain(&s.config.Server) if err != nil { @@ -678,25 +656,39 @@ func (s *Server) getProxyCacheStats() ProxyCacheStats { return total } -// registerStaticHandler 注册静态文件处理器。 +// registerStaticHandlers 注册静态文件处理器。 // -// 为路由器注册静态文件服务,支持文件缓存和预压缩文件。 +// 为路由器注册静态文件服务,支持多个静态目录、文件缓存和预压缩文件。 // // 参数: // - router: 路由器实例,用于注册路由规则 // - cfg: 服务器配置,包含静态文件和压缩设置 -func (s *Server) registerStaticHandler(router *handler.Router, cfg *config.ServerConfig) { - staticHandler := handler.NewStaticHandler( - cfg.Static.Root, - cfg.Static.Index, - true, // useSendfile - ) - if s.fileCache != nil { - staticHandler.SetFileCache(s.fileCache) +func (s *Server) registerStaticHandlers(router *handler.Router, cfg *config.ServerConfig) { + for _, static := range cfg.Static { + path := static.Path + if path == "" { + path = "/" + } + + 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) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a6a97c7..4c02d84 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -27,10 +27,11 @@ func TestNew(t *testing.T) { cfg := &config.Config{ Server: config.ServerConfig{ Listen: ":8080", - Static: config.StaticConfig{ + Static: []config.StaticConfig{{ + Path: "/", Root: "./static", Index: []string{"index.html"}, - }, + }}, }, }