diff --git a/docs/app-config.md b/docs/app-config.md index d4a9635b..24e79a6e 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -84,6 +84,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 这类参数必须以结构化方式校验、保存并参与版本渲染。 * `OpenRestyResolvers` 由管理端性能页面维护,支持填写多个 DNS 服务器 IP;留空时不额外生成 `resolver` 指令。 +* `OpenRestyCacheEnabled` 用于启用缓存基础设施与全局默认参数;实际是否缓存、按 URL / 后缀 / 路径等命中策略由各条 `proxy_routes` 单独决定,不再默认对所有规则开启缓存。 ### 1.5 前端构建环境变量 | 环境变量 | 作用 | 默认值 | diff --git a/openflare_server/model/proxy_route.go b/openflare_server/model/proxy_route.go index 32a55b24..7fbbf16a 100644 --- a/openflare_server/model/proxy_route.go +++ b/openflare_server/model/proxy_route.go @@ -11,6 +11,9 @@ type ProxyRoute struct { EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"` CertID *uint `json:"cert_id"` RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"` + CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"` + CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"` + CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"` CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"` Remark string `json:"remark" gorm:"size:255"` CreatedAt time.Time `json:"created_at"` @@ -46,6 +49,9 @@ func (route *ProxyRoute) Update() error { "enable_https": route.EnableHTTPS, "cert_id": route.CertID, "redirect_http": route.RedirectHTTP, + "cache_enabled": route.CacheEnabled, + "cache_policy": route.CachePolicy, + "cache_rules": route.CacheRules, "custom_headers": route.CustomHeaders, "remark": route.Remark, }).Error diff --git a/openflare_server/router/api_phase1_test.go b/openflare_server/router/api_phase1_test.go index 88396a5a..4288a9fa 100644 --- a/openflare_server/router/api_phase1_test.go +++ b/openflare_server/router/api_phase1_test.go @@ -44,11 +44,14 @@ func TestPhase1PublishLifecycle(t *testing.T) { token := prepareRootToken(t) createBody := map[string]any{ - "domain": "app.example.com", - "origin_url": "https://origin-a.internal", - "origin_host": "origin-a.internal", - "enabled": true, - "remark": "primary route", + "domain": "app.example.com", + "origin_url": "https://origin-a.internal", + "origin_host": "origin-a.internal", + "enabled": true, + "cache_enabled": true, + "cache_policy": "path_prefix", + "cache_rules": []string{"/assets", "/static"}, + "remark": "primary route", } resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody) var createdRoute model.ProxyRoute @@ -59,6 +62,12 @@ func TestPhase1PublishLifecycle(t *testing.T) { if createdRoute.OriginHost != "origin-a.internal" { t.Fatalf("unexpected created route origin host: %s", createdRoute.OriginHost) } + if !createdRoute.CacheEnabled || createdRoute.CachePolicy != "path_prefix" { + t.Fatalf("expected route cache settings to persist, got %+v", createdRoute) + } + if !strings.Contains(createdRoute.CacheRules, "/assets") { + t.Fatalf("expected route cache rules to persist, got %s", createdRoute.CacheRules) + } resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil) var routes []model.ProxyRoute @@ -103,11 +112,14 @@ func TestPhase1PublishLifecycle(t *testing.T) { initialRendered := version1.RenderedConfig updateBody := map[string]any{ - "domain": "app.example.com", - "origin_url": "https://origin-b.internal", - "origin_host": "origin-b.internal", - "enabled": true, - "remark": "updated route", + "domain": "app.example.com", + "origin_url": "https://origin-b.internal", + "origin_host": "origin-b.internal", + "enabled": true, + "cache_enabled": true, + "cache_policy": "path_exact", + "cache_rules": []string{"/robots.txt"}, + "remark": "updated route", } routePath := "/api/proxy-routes/" + toString(createdRoute.ID) resp = performJSONRequest(t, engine, token, http.MethodPost, routePath+"/update", updateBody) @@ -118,6 +130,9 @@ func TestPhase1PublishLifecycle(t *testing.T) { if createdRoute.OriginHost != "origin-b.internal" { t.Fatalf("unexpected updated route origin host: %s", createdRoute.OriginHost) } + if createdRoute.CachePolicy != "path_exact" || !strings.Contains(createdRoute.CacheRules, "/robots.txt") { + t.Fatalf("expected updated route cache rules to persist, got %+v", createdRoute) + } resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) var version2 model.ConfigVersion diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index 61066d2b..3df3731d 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -10,6 +10,7 @@ import ( "net/url" "openflare/common" "openflare/model" + "regexp" "sort" "strings" "time" @@ -65,10 +66,19 @@ type snapshotRoute struct { EnableHTTPS bool `json:"enable_https"` CertID *uint `json:"cert_id,omitempty"` RedirectHTTP bool `json:"redirect_http"` + CacheEnabled bool `json:"cache_enabled"` + CachePolicy string `json:"cache_policy,omitempty"` + CacheRules []string `json:"cache_rules,omitempty"` CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"` Remark string `json:"remark,omitempty"` } +type routeCacheConfig struct { + Enabled bool + Policy string + Rules []string +} + type openRestyConfigSnapshot struct { WorkerProcesses string `json:"worker_processes"` WorkerConnections int `json:"worker_connections"` @@ -391,6 +401,10 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { if err != nil { return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) } + cacheRules, err := decodeStoredCacheRules(route.CacheRules) + if err != nil { + return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) + } items = append(items, snapshotRoute{ Domain: route.Domain, OriginURL: route.OriginURL, @@ -399,6 +413,9 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { EnableHTTPS: route.EnableHTTPS, CertID: route.CertID, RedirectHTTP: route.RedirectHTTP, + CacheEnabled: route.CacheEnabled, + CachePolicy: route.CachePolicy, + CacheRules: cacheRules, CustomHeaders: customHeaders, Remark: route.Remark, }) @@ -435,14 +452,27 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute { if err == nil { routes[index].CustomHeaders = normalizedHeaders } + normalizedCacheRules, err := normalizeCacheRules(routes[index].CacheEnabled, routes[index].CachePolicy, routes[index].CacheRules) + if err == nil { + routes[index].CachePolicy = normalizeCachePolicy(routes[index].CacheEnabled, routes[index].CachePolicy) + routes[index].CacheRules = normalizedCacheRules + } } return routes } func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool { - if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || !uintPointerEqual(left.CertID, right.CertID) { + if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintPointerEqual(left.CertID, right.CertID) { return false } + if len(left.CacheRules) != len(right.CacheRules) { + return false + } + for index := range left.CacheRules { + if left.CacheRules[index] != right.CacheRules[index] { + return false + } + } if len(left.CustomHeaders) != len(right.CustomHeaders) { return false } @@ -611,8 +641,17 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) if err != nil { return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) } + cacheRules, err := decodeStoredCacheRules(route.CacheRules) + if err != nil { + return "", nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) + } + cacheConfig := routeCacheConfig{ + Enabled: route.CacheEnabled, + Policy: route.CachePolicy, + Rules: cacheRules, + } if !route.EnableHTTPS { - builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cfg)) + builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, cfg)) continue } if route.CertID == nil || *route.CertID == 0 { @@ -629,9 +668,9 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) if route.RedirectHTTP { builder.WriteString(renderHTTPRedirectServer(route.Domain)) } else { - builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cfg)) + builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, cfg)) } - builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cfg)) + builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cacheConfig, cfg)) } return builder.String(), dedupeSupportFiles(supportFiles), nil } @@ -768,18 +807,18 @@ func nextVersionNumber(now time.Time) (string, error) { return fmt.Sprintf("%s-%03d", prefix, count+1), nil } -func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cfg openRestyConfigSnapshot) string { - return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s }\n}\n\n", domain, renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL, cfg)) +func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, cfg openRestyConfigSnapshot) string { + return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, renderProxyHeaderBlock(originURL, originHost, customHeaders), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, cfg)) } func renderHTTPRedirectServer(domain string) string { return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", domain) } -func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cfg openRestyConfigSnapshot) string { +func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, cfg openRestyConfigSnapshot) string { certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID)) keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID)) - return fmt.Sprintf("server {\n listen 443 ssl http2;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL, cfg)) + return fmt.Sprintf("server {\n listen 443 ssl http2;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, cfg)) } func renderConnectionUpgradeMap() string { @@ -812,12 +851,74 @@ func renderProxyHeaderBlock(originURL string, originHost string, customHeaders [ for _, header := range customHeaders { builder.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", header.Key, quoteNginxHeaderValue(header.Value))) } - if common.OpenRestyCacheEnabled { - builder.WriteString(" proxy_cache openflare_cache;\n") - } return builder.String() } +func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg openRestyConfigSnapshot) string { + if !cfg.CacheEnabled || !cacheConfig.Enabled { + return "" + } + var builder strings.Builder + builder.WriteString(" set $openflare_skip_cache 0;\n") + builder.WriteString(" if ($request_method != GET) {\n set $openflare_skip_cache 1;\n }\n") + builder.WriteString(" if ($http_authorization != \"\") {\n set $openflare_skip_cache 1;\n }\n") + builder.WriteString(" if ($http_cookie ~* \"(session|sess|token|auth|jwt|logged_in|remember|laravel_session|connect\\\\.sid|_session)\") {\n set $openflare_skip_cache 1;\n }\n") + builder.WriteString(" if ($http_cache_control ~* \"(no-cache|no-store|private)\") {\n set $openflare_skip_cache 1;\n }\n") + if policyCondition := renderRouteCachePolicyCondition(cacheConfig); policyCondition != "" { + builder.WriteString(policyCondition) + } + builder.WriteString(" proxy_cache openflare_cache;\n") + builder.WriteString(" proxy_cache_methods GET;\n") + builder.WriteString(" proxy_cache_bypass $openflare_skip_cache;\n") + builder.WriteString(" proxy_no_cache $openflare_skip_cache;\n") + return builder.String() +} + +func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string { + switch cacheConfig.Policy { + case proxyRouteCachePolicySuffix: + return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(cacheConfig.Rules))) + case proxyRouteCachePolicyPathPrefix: + return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathPrefixMatchPattern(cacheConfig.Rules))) + case proxyRouteCachePolicyPathExact: + return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathExactMatchPattern(cacheConfig.Rules))) + default: + return "" + } +} + +func buildSuffixMatchPattern(rules []string) string { + parts := make([]string, 0, len(rules)) + for _, rule := range rules { + parts = append(parts, regexp.QuoteMeta(rule)) + } + return fmt.Sprintf("\\.(?:%s)$", strings.Join(parts, "|")) +} + +func buildPathPrefixMatchPattern(rules []string) string { + parts := make([]string, 0, len(rules)) + for _, rule := range rules { + trimmed := strings.TrimRight(rule, "/") + if trimmed == "" { + trimmed = "/" + } + if trimmed == "/" { + parts = append(parts, "/") + continue + } + parts = append(parts, fmt.Sprintf("%s(?:/|$)", regexp.QuoteMeta(trimmed))) + } + return fmt.Sprintf("^(?:%s)", strings.Join(parts, "|")) +} + +func buildPathExactMatchPattern(rules []string) string { + parts := make([]string, 0, len(rules)) + for _, rule := range rules { + parts = append(parts, regexp.QuoteMeta(rule)) + } + return fmt.Sprintf("^(?:%s)$", strings.Join(parts, "|")) +} + func renderProxyPassBlock(originURL string, cfg openRestyConfigSnapshot) string { parsed, err := url.Parse(originURL) if err != nil || parsed.Host == "" || parsed.Scheme == "" { diff --git a/openflare_server/service/https_phase1_test.go b/openflare_server/service/https_phase1_test.go index 8c49c0f1..b15746e5 100644 --- a/openflare_server/service/https_phase1_test.go +++ b/openflare_server/service/https_phase1_test.go @@ -149,6 +149,86 @@ func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) { } } +func TestCreateProxyRouteRejectsCachePolicyWithoutRules(t *testing.T) { + setupServiceTestDB(t) + + _, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "cache.example.com", + OriginURL: "https://origin.internal", + Enabled: true, + CacheEnabled: true, + CachePolicy: proxyRouteCachePolicySuffix, + }) + if err == nil || !strings.Contains(err.Error(), "至少填写一个后缀") { + t.Fatalf("expected cache rule validation error, got %v", err) + } +} + +func TestPublishConfigVersionRendersRouteLevelCachePolicy(t *testing.T) { + setupServiceTestDB(t) + if err := model.UpdateOption("OpenRestyCacheEnabled", "true"); err != nil { + t.Fatalf("UpdateOption OpenRestyCacheEnabled failed: %v", err) + } + if err := model.UpdateOption("OpenRestyCachePath", "/var/cache/openresty/openflare"); err != nil { + t.Fatalf("UpdateOption OpenRestyCachePath failed: %v", err) + } + + _, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "static.example.com", + OriginURL: "https://origin.internal", + Enabled: true, + CacheEnabled: true, + CachePolicy: proxyRouteCachePolicySuffix, + CacheRules: []string{"jpg", ".css", "js"}, + }) + if err != nil { + t.Fatalf("CreateProxyRoute cached failed: %v", err) + } + _, err = CreateProxyRoute(ProxyRouteInput{ + Domain: "nocache.example.com", + OriginURL: "https://origin.internal", + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateProxyRoute uncached failed: %v", err) + } + + result, err := PublishConfigVersion("root") + if err != nil { + t.Fatalf("PublishConfigVersion failed: %v", err) + } + if !strings.Contains(result.Version.MainConfig, "proxy_cache_path /var/cache/openresty/openflare") { + t.Fatal("expected main config to include cache zone when cache infra is enabled") + } + if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_methods GET;") { + t.Fatal("expected rendered config to only cache GET requests") + } + if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_bypass $openflare_skip_cache;") { + t.Fatal("expected rendered config to bypass cache when request is unsafe") + } + if !strings.Contains(result.Version.RenderedConfig, "proxy_no_cache $openflare_skip_cache;") { + t.Fatal("expected rendered config to avoid storing unsafe requests in cache") + } + if !strings.Contains(result.Version.RenderedConfig, "if ($http_authorization != \"\")") { + t.Fatal("expected rendered config to bypass authenticated requests") + } + if !strings.Contains(result.Version.RenderedConfig, "if ($request_method != GET)") { + t.Fatal("expected rendered config to bypass non-GET requests") + } + if !strings.Contains(result.Version.RenderedConfig, "if ($uri !~* \"\\\\.(?:jpg|css|js)$\")") { + t.Fatal("expected rendered config to render suffix cache matching rule") + } + if strings.Count(result.Version.RenderedConfig, "proxy_cache openflare_cache;") != 1 { + t.Fatal("expected only cache-enabled route to include proxy_cache directive") + } + if !strings.Contains(result.Version.SnapshotJSON, `"cache_enabled":true`) { + t.Fatal("expected snapshot to include route cache toggle") + } + if !strings.Contains(result.Version.SnapshotJSON, `"cache_policy":"suffix"`) { + t.Fatal("expected snapshot to include route cache policy") + } +} + func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) { setupServiceTestDB(t) diff --git a/openflare_server/service/proxy_route.go b/openflare_server/service/proxy_route.go index 474eaea6..3fabb2da 100644 --- a/openflare_server/service/proxy_route.go +++ b/openflare_server/service/proxy_route.go @@ -11,6 +11,13 @@ import ( var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +const ( + proxyRouteCachePolicyURL = "url" + proxyRouteCachePolicySuffix = "suffix" + proxyRouteCachePolicyPathPrefix = "path_prefix" + proxyRouteCachePolicyPathExact = "path_exact" +) + type ProxyRouteCustomHeaderInput struct { Key string `json:"key"` Value string `json:"value"` @@ -24,6 +31,9 @@ type ProxyRouteInput struct { EnableHTTPS bool `json:"enable_https"` CertID *uint `json:"cert_id"` RedirectHTTP bool `json:"redirect_http"` + CacheEnabled bool `json:"cache_enabled"` + CachePolicy string `json:"cache_policy"` + CacheRules []string `json:"cache_rules"` CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"` Remark string `json:"remark"` } @@ -77,10 +87,19 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro originURL := strings.TrimSpace(input.OriginURL) originHost := strings.TrimSpace(input.OriginHost) remark := strings.TrimSpace(input.Remark) + cachePolicy := strings.TrimSpace(input.CachePolicy) + cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules) + if err != nil { + return nil, err + } customHeaders, err := normalizeCustomHeaders(input.CustomHeaders) if err != nil { return nil, err } + cacheRulesJSON, err := json.Marshal(cacheRules) + if err != nil { + return nil, err + } customHeadersJSON, err := json.Marshal(customHeaders) if err != nil { return nil, err @@ -122,6 +141,9 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro route.EnableHTTPS = input.EnableHTTPS route.CertID = input.CertID route.RedirectHTTP = input.RedirectHTTP + route.CacheEnabled = input.CacheEnabled + route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy) + route.CacheRules = string(cacheRulesJSON) route.CustomHeaders = string(customHeadersJSON) route.Remark = remark return route, nil @@ -167,6 +189,108 @@ func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error return normalizeCustomHeaders(headers) } +func normalizeCachePolicy(enabled bool, raw string) string { + if !enabled { + return "" + } + policy := strings.TrimSpace(raw) + if policy == "" { + return proxyRouteCachePolicyURL + } + return policy +} + +func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]string, error) { + if !enabled { + return []string{}, nil + } + policy := normalizeCachePolicy(enabled, rawPolicy) + switch policy { + case proxyRouteCachePolicyURL: + return []string{}, nil + case proxyRouteCachePolicySuffix: + return normalizeCacheSuffixRules(rules) + case proxyRouteCachePolicyPathPrefix: + return normalizeCachePathRules(rules, true) + case proxyRouteCachePolicyPathExact: + return normalizeCachePathRules(rules, false) + default: + return nil, errors.New("缓存策略不支持") + } +} + +func normalizeCacheSuffixRules(rules []string) ([]string, error) { + normalized := make([]string, 0, len(rules)) + seen := make(map[string]struct{}, len(rules)) + for _, rule := range rules { + item := strings.TrimSpace(strings.TrimPrefix(rule, ".")) + if item == "" { + continue + } + if strings.ContainsAny(item, "/\\ \t\r\n") { + return nil, errors.New("缓存后缀格式不合法") + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + normalized = append(normalized, item) + } + if len(normalized) == 0 { + return nil, errors.New("按后缀缓存时至少填写一个后缀") + } + return normalized, nil +} + +func normalizeCachePathRules(rules []string, allowPrefix bool) ([]string, error) { + normalized := make([]string, 0, len(rules)) + seen := make(map[string]struct{}, len(rules)) + for _, rule := range rules { + item := strings.TrimSpace(rule) + if item == "" { + continue + } + if !strings.HasPrefix(item, "/") || strings.Contains(item, "://") || strings.ContainsAny(item, " \t\r\n") { + return nil, errors.New("缓存路径规则格式不合法") + } + if !allowPrefix && strings.HasSuffix(item, "/") && len(item) > 1 { + item = strings.TrimRight(item, "/") + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + normalized = append(normalized, item) + } + if len(normalized) == 0 { + if allowPrefix { + return nil, errors.New("按路径前缀缓存时至少填写一个路径") + } + return nil, errors.New("按精确路径缓存时至少填写一个路径") + } + return normalized, nil +} + +func decodeStoredCacheRules(raw string) ([]string, error) { + text := strings.TrimSpace(raw) + if text == "" { + return []string{}, nil + } + var rules []string + if err := json.Unmarshal([]byte(text), &rules); err != nil { + return nil, errors.New("缓存规则格式不合法") + } + normalized := make([]string, 0, len(rules)) + for _, rule := range rules { + item := strings.TrimSpace(rule) + if item == "" { + continue + } + normalized = append(normalized, item) + } + return normalized, nil +} + func validateOriginURL(raw string) error { if raw == "" { return errors.New("源站地址不能为空") diff --git a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx index 7125d094..0da899bc 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx @@ -50,6 +50,8 @@ const customHeaderSchema = z.object({ value: z.string(), }); +const cachePolicyValues = ['url', 'suffix', 'path_prefix', 'path_exact'] as const; + const proxyRouteSchema = z .object({ domain: z.string().trim().min(1, '请输入域名'), @@ -91,6 +93,9 @@ const proxyRouteSchema = z enable_https: z.boolean(), cert_id: z.string(), redirect_http: z.boolean(), + cache_enabled: z.boolean(), + cache_policy: z.enum(cachePolicyValues), + cache_rules_text: z.string(), custom_headers: z.array(customHeaderSchema).min(1), remark: z.string().max(255, '备注不能超过 255 个字符'), }) @@ -103,6 +108,17 @@ const proxyRouteSchema = z }); } + if (value.cache_enabled) { + const cacheRules = parseCacheRulesText(value.cache_rules_text); + if (value.cache_policy !== 'url' && cacheRules.length === 0) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['cache_rules_text'], + message: '当前缓存策略至少需要填写一条规则', + }); + } + } + value.custom_headers.forEach((header, index) => { const key = header.key.trim(); const headerValue = header.value.trim(); @@ -152,6 +168,9 @@ const defaultValues: ProxyRouteFormValues = { enable_https: false, cert_id: '', redirect_http: false, + cache_enabled: false, + cache_policy: 'url', + cache_rules_text: '', custom_headers: [{ key: '', value: '' }], remark: '', }; @@ -195,6 +214,52 @@ function parseCustomHeaders(rawValue: string) { } } +function parseCacheRules(rawValue: string) { + if (!rawValue) { + return [] as string[]; + } + + try { + const parsed = JSON.parse(rawValue) as string[]; + return Array.isArray(parsed) ? parsed.filter(Boolean) : []; + } catch { + return []; + } +} + +function parseCacheRulesText(value: string) { + return value + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function buildCachePolicyLabel(policy: string) { + switch (policy) { + case 'suffix': + return '按后缀'; + case 'path_prefix': + return '按前缀'; + case 'path_exact': + return '按路径'; + default: + return '按 URL'; + } +} + +function getCacheRulesHint(policy: string) { + switch (policy) { + case 'suffix': + return '每行一个后缀,例如:jpg、css、js。'; + case 'path_prefix': + return '每行一个路径前缀,例如:/assets、/static/images。'; + case 'path_exact': + return '每行一个精确路径,例如:/robots.txt、/manifest.json。'; + default: + return '按 URL 缓存时无需额外规则,系统会按请求 URL 粒度缓存。'; + } +} + function buildCertificateLabel(certificate: TlsCertificateItem) { return certificate.not_after ? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})` @@ -211,6 +276,11 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload { cert_id: values.enable_https && values.cert_id ? Number(values.cert_id) : null, redirect_http: values.enable_https ? values.redirect_http : false, + cache_enabled: values.cache_enabled, + cache_policy: values.cache_enabled ? values.cache_policy : 'url', + cache_rules: values.cache_enabled + ? parseCacheRulesText(values.cache_rules_text) + : [], custom_headers: values.custom_headers .map((item) => ({ key: item.key.trim(), value: item.value.trim() })) .filter((item) => item.key || item.value), @@ -220,6 +290,7 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload { function toFormValues(route: ProxyRouteItem): ProxyRouteFormValues { const headers = parseCustomHeaders(route.custom_headers); + const cacheRules = parseCacheRules(route.cache_rules); return { domain: route.domain, @@ -229,6 +300,9 @@ function toFormValues(route: ProxyRouteItem): ProxyRouteFormValues { enable_https: route.enable_https, cert_id: route.cert_id ? String(route.cert_id) : '', redirect_http: route.redirect_http, + cache_enabled: route.cache_enabled, + cache_policy: (route.cache_policy || 'url') as ProxyRouteFormValues['cache_policy'], + cache_rules_text: cacheRules.join('\n'), custom_headers: headers.length > 0 ? headers : [{ key: '', value: '' }], remark: route.remark || '', }; @@ -288,6 +362,14 @@ export function ProxyRoutesPage() { control: form.control, name: 'redirect_http', }); + const watchedCacheEnabled = useWatch({ + control: form.control, + name: 'cache_enabled', + }); + const watchedCachePolicy = useWatch({ + control: form.control, + name: 'cache_policy', + }); const watchedCertId = useWatch({ control: form.control, name: 'cert_id' }); const routesQuery = useQuery({ @@ -514,6 +596,7 @@ export function ProxyRoutesPage() {
+ {cacheRules.length > 0 + ? `${cacheRules.length} 条规则` + : '按 URL 粒度缓存'} +
+