mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 22:26:38 +08:00
772 lines
23 KiB
Go
772 lines
23 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"openflare/model"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
|
var proxyRouteLimitRatePattern = regexp.MustCompile(`^\d+(?:[kKmM])?$`)
|
|
|
|
const (
|
|
proxyRouteCachePolicyURL = "url"
|
|
proxyRouteCachePolicySuffix = "suffix"
|
|
proxyRouteCachePolicyPathPrefix = "path_prefix"
|
|
proxyRouteCachePolicyPathExact = "path_exact"
|
|
)
|
|
|
|
type ProxyRouteCustomHeaderInput struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type ProxyRouteInput struct {
|
|
SiteName string `json:"site_name"`
|
|
Domain string `json:"domain"`
|
|
Domains []string `json:"domains"`
|
|
OriginID *uint `json:"origin_id"`
|
|
OriginURL string `json:"origin_url"`
|
|
OriginScheme string `json:"origin_scheme"`
|
|
OriginAddress string `json:"origin_address"`
|
|
OriginPort string `json:"origin_port"`
|
|
OriginURI string `json:"origin_uri"`
|
|
OriginHost string `json:"origin_host"`
|
|
Upstreams []string `json:"upstreams"`
|
|
Enabled bool `json:"enabled"`
|
|
EnableHTTPS bool `json:"enable_https"`
|
|
CertID *uint `json:"cert_id"`
|
|
RedirectHTTP bool `json:"redirect_http"`
|
|
LimitConnPerServer int `json:"limit_conn_per_server"`
|
|
LimitConnPerIP int `json:"limit_conn_per_ip"`
|
|
LimitRate string `json:"limit_rate"`
|
|
CacheEnabled bool `json:"cache_enabled"`
|
|
CachePolicy string `json:"cache_policy"`
|
|
CacheRules []string `json:"cache_rules"`
|
|
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"`
|
|
Remark string `json:"remark"`
|
|
}
|
|
|
|
type ProxyRouteView struct {
|
|
ID uint `json:"id"`
|
|
SiteName string `json:"site_name"`
|
|
Domain string `json:"domain"`
|
|
Domains []string `json:"domains"`
|
|
PrimaryDomain string `json:"primary_domain"`
|
|
DomainCount int `json:"domain_count"`
|
|
OriginID *uint `json:"origin_id"`
|
|
OriginURL string `json:"origin_url"`
|
|
OriginHost string `json:"origin_host"`
|
|
Upstreams string `json:"upstreams"`
|
|
UpstreamList []string `json:"upstream_list"`
|
|
Enabled bool `json:"enabled"`
|
|
EnableHTTPS bool `json:"enable_https"`
|
|
CertID *uint `json:"cert_id"`
|
|
RedirectHTTP bool `json:"redirect_http"`
|
|
LimitConnPerServer int `json:"limit_conn_per_server"`
|
|
LimitConnPerIP int `json:"limit_conn_per_ip"`
|
|
LimitRate string `json:"limit_rate"`
|
|
CacheEnabled bool `json:"cache_enabled"`
|
|
CachePolicy string `json:"cache_policy"`
|
|
CacheRules string `json:"cache_rules"`
|
|
CacheRuleList []string `json:"cache_rule_list"`
|
|
CustomHeaders string `json:"custom_headers"`
|
|
CustomHeaderList []ProxyRouteCustomHeaderInput `json:"custom_header_list"`
|
|
Remark string `json:"remark"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func ListProxyRoutes() ([]*ProxyRouteView, error) {
|
|
routes, err := model.ListProxyRoutes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildProxyRouteViews(routes)
|
|
}
|
|
|
|
func GetProxyRoute(id uint) (*ProxyRouteView, error) {
|
|
route, err := model.GetProxyRouteByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildProxyRouteView(route)
|
|
}
|
|
|
|
func CreateProxyRoute(input ProxyRouteInput) (*ProxyRouteView, error) {
|
|
route, err := buildProxyRoute(nil, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = route.Insert(); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return nil, errors.New("proxy route identity already exists")
|
|
}
|
|
return nil, err
|
|
}
|
|
return buildProxyRouteView(route)
|
|
}
|
|
|
|
func UpdateProxyRoute(id uint, input ProxyRouteInput) (*ProxyRouteView, error) {
|
|
route, err := model.GetProxyRouteByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
route, err = buildProxyRoute(route, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = route.Update(); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return nil, errors.New("proxy route identity already exists")
|
|
}
|
|
return nil, err
|
|
}
|
|
return buildProxyRouteView(route)
|
|
}
|
|
|
|
func DeleteProxyRoute(id uint) error {
|
|
route, err := model.GetProxyRouteByID(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return route.Delete()
|
|
}
|
|
|
|
func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) {
|
|
domains, err := normalizeProxyRouteDomainsInput(route, input.Domain, input.Domains)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
domain := domains[0]
|
|
siteName := normalizeProxyRouteSiteNameInput(route, input.SiteName, domain)
|
|
|
|
originURL, originID, err := resolveProxyRoutePrimaryOrigin(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
originHost := strings.TrimSpace(input.OriginHost)
|
|
remark := strings.TrimSpace(input.Remark)
|
|
upstreams, err := normalizeUpstreams(originURL, input.Upstreams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
limitConnPerServer, err := normalizeProxyRouteLimitConnValue(input.LimitConnPerServer, "limit_conn_per_server")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitConnPerIP, err := normalizeProxyRouteLimitConnValue(input.LimitConnPerIP, "limit_conn_per_ip")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitRate, err := normalizeProxyRouteLimitRate(input.LimitRate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cacheRulesJSON, err := json.Marshal(cacheRules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
upstreamsJSON, err := json.Marshal(upstreams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
customHeadersJSON, err := json.Marshal(customHeaders)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
domainsJSON, err := json.Marshal(domains)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := validateProxyRouteSiteName(siteName); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateProxyRouteIdentityUniqueness(route, siteName, domains); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateOriginHost(originHost); err != nil {
|
|
return nil, err
|
|
}
|
|
if !input.EnableHTTPS {
|
|
input.RedirectHTTP = false
|
|
input.CertID = nil
|
|
}
|
|
if input.EnableHTTPS {
|
|
if input.CertID == nil || *input.CertID == 0 {
|
|
return nil, errors.New("must select a certificate when HTTPS is enabled")
|
|
}
|
|
if _, err := model.GetTLSCertificateByID(*input.CertID); err != nil {
|
|
return nil, errors.New("selected certificate does not exist")
|
|
}
|
|
}
|
|
if input.RedirectHTTP && !input.EnableHTTPS {
|
|
return nil, errors.New("redirect_http requires enable_https")
|
|
}
|
|
|
|
if route == nil {
|
|
route = &model.ProxyRoute{}
|
|
}
|
|
route.SiteName = siteName
|
|
route.Domain = domain
|
|
route.Domains = string(domainsJSON)
|
|
route.OriginID = originID
|
|
route.OriginURL = upstreams[0]
|
|
route.OriginHost = originHost
|
|
route.Upstreams = string(upstreamsJSON)
|
|
route.Enabled = input.Enabled
|
|
route.EnableHTTPS = input.EnableHTTPS
|
|
route.CertID = input.CertID
|
|
route.RedirectHTTP = input.RedirectHTTP
|
|
route.LimitConnPerServer = limitConnPerServer
|
|
route.LimitConnPerIP = limitConnPerIP
|
|
route.LimitRate = limitRate
|
|
route.CacheEnabled = input.CacheEnabled
|
|
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
|
|
route.CacheRules = string(cacheRulesJSON)
|
|
route.CustomHeaders = string(customHeadersJSON)
|
|
route.Remark = remark
|
|
return route, nil
|
|
}
|
|
|
|
func buildProxyRouteViews(routes []*model.ProxyRoute) ([]*ProxyRouteView, error) {
|
|
views := make([]*ProxyRouteView, 0, len(routes))
|
|
for _, route := range routes {
|
|
view, err := buildProxyRouteView(route)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
views = append(views, view)
|
|
}
|
|
return views, nil
|
|
}
|
|
|
|
func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
|
|
if route == nil {
|
|
return nil, errors.New("proxy route is nil")
|
|
}
|
|
domains, err := decodeStoredDomains(route.Domains, route.Domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
primaryDomain := domains[0]
|
|
return &ProxyRouteView{
|
|
ID: route.ID,
|
|
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, primaryDomain),
|
|
Domain: primaryDomain,
|
|
Domains: domains,
|
|
PrimaryDomain: primaryDomain,
|
|
DomainCount: len(domains),
|
|
OriginID: route.OriginID,
|
|
OriginURL: route.OriginURL,
|
|
OriginHost: route.OriginHost,
|
|
Upstreams: route.Upstreams,
|
|
UpstreamList: upstreams,
|
|
Enabled: route.Enabled,
|
|
EnableHTTPS: route.EnableHTTPS,
|
|
CertID: route.CertID,
|
|
RedirectHTTP: route.RedirectHTTP,
|
|
LimitConnPerServer: route.LimitConnPerServer,
|
|
LimitConnPerIP: route.LimitConnPerIP,
|
|
LimitRate: route.LimitRate,
|
|
CacheEnabled: route.CacheEnabled,
|
|
CachePolicy: route.CachePolicy,
|
|
CacheRules: route.CacheRules,
|
|
CacheRuleList: cacheRules,
|
|
CustomHeaders: route.CustomHeaders,
|
|
CustomHeaderList: customHeaders,
|
|
Remark: route.Remark,
|
|
CreatedAt: route.CreatedAt,
|
|
UpdatedAt: route.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeProxyRouteSiteNameInput(route *model.ProxyRoute, raw string, primaryDomain string) string {
|
|
siteName := strings.TrimSpace(raw)
|
|
if siteName != "" {
|
|
return siteName
|
|
}
|
|
if route != nil && strings.TrimSpace(route.SiteName) != "" {
|
|
return strings.TrimSpace(route.SiteName)
|
|
}
|
|
return primaryDomain
|
|
}
|
|
|
|
func normalizeProxyRouteDomainValue(raw string) string {
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
}
|
|
|
|
func normalizeProxyRouteDomainsInput(route *model.ProxyRoute, rawDomain string, rawDomains []string) ([]string, error) {
|
|
if len(rawDomains) > 0 {
|
|
domains, err := normalizeProxyRouteDomains(rawDomains)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
domain := normalizeProxyRouteDomainValue(rawDomain)
|
|
if domain != "" && domain != domains[0] {
|
|
return nil, errors.New("domain must match domains[0]")
|
|
}
|
|
return domains, nil
|
|
}
|
|
|
|
if route != nil {
|
|
existingDomains, err := decodeStoredDomains(route.Domains, route.Domain)
|
|
if err == nil && len(existingDomains) > 0 {
|
|
domain := normalizeProxyRouteDomainValue(rawDomain)
|
|
if domain == "" || domain == existingDomains[0] {
|
|
return existingDomains, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return normalizeProxyRouteDomains([]string{rawDomain})
|
|
}
|
|
|
|
func normalizeProxyRouteDomains(rawDomains []string) ([]string, error) {
|
|
normalized := make([]string, 0, len(rawDomains))
|
|
seen := make(map[string]struct{}, len(rawDomains))
|
|
for _, rawDomain := range rawDomains {
|
|
domain := normalizeProxyRouteDomainValue(rawDomain)
|
|
if domain == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(domain, "://") || strings.Contains(domain, "/") {
|
|
return nil, errors.New("domain format is invalid")
|
|
}
|
|
if _, ok := seen[domain]; ok {
|
|
continue
|
|
}
|
|
seen[domain] = struct{}{}
|
|
normalized = append(normalized, domain)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil, errors.New("at least one domain is required")
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func validateProxyRouteSiteName(siteName string) error {
|
|
if strings.TrimSpace(siteName) == "" {
|
|
return errors.New("site_name cannot be empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateProxyRouteIdentityUniqueness(route *model.ProxyRoute, siteName string, domains []string) error {
|
|
routes, err := model.ListProxyRoutes()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
currentID := uint(0)
|
|
if route != nil {
|
|
currentID = route.ID
|
|
}
|
|
|
|
for _, item := range routes {
|
|
if item == nil || item.ID == currentID {
|
|
continue
|
|
}
|
|
existingSiteName := normalizeProxyRouteSiteNameInput(item, item.SiteName, item.Domain)
|
|
if existingSiteName == siteName {
|
|
return errors.New("site_name already exists")
|
|
}
|
|
|
|
existingDomains, err := decodeStoredDomains(item.Domains, item.Domain)
|
|
if err != nil {
|
|
return fmt.Errorf("existing route %d domains are invalid: %w", item.ID, err)
|
|
}
|
|
existingSet := make(map[string]struct{}, len(existingDomains))
|
|
for _, existingDomain := range existingDomains {
|
|
existingSet[existingDomain] = struct{}{}
|
|
}
|
|
for _, domain := range domains {
|
|
if _, ok := existingSet[domain]; ok {
|
|
return fmt.Errorf("domain %s already exists", domain)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) {
|
|
if value < 0 {
|
|
return 0, fmt.Errorf("%s must be greater than or equal to 0", field)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeProxyRouteLimitRate(raw string) (string, error) {
|
|
normalized := strings.ToLower(strings.TrimSpace(raw))
|
|
if normalized == "" || normalized == "0" {
|
|
return "", nil
|
|
}
|
|
if !proxyRouteLimitRatePattern.MatchString(normalized) {
|
|
return "", errors.New("limit_rate must be a number or use the 512k / 1m format")
|
|
}
|
|
if strings.TrimRight(normalized, "km") == "" {
|
|
return "", nil
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error) {
|
|
if hasStructuredOriginInput(input) {
|
|
scheme, err := normalizeOriginScheme(input.OriginScheme)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
port, err := normalizeOriginPort(input.OriginPort)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
uri, err := normalizeOriginURI(input.OriginURI)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if input.OriginID != nil && *input.OriginID != 0 {
|
|
origin, err := model.GetOriginByID(*input.OriginID)
|
|
if err != nil {
|
|
return "", nil, errors.New("selected origin does not exist")
|
|
}
|
|
originURL, err := buildOriginURLFromParts(
|
|
scheme,
|
|
origin.Address,
|
|
port,
|
|
uri,
|
|
)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return originURL, &origin.ID, nil
|
|
}
|
|
|
|
address := normalizeOriginAddress(input.OriginAddress)
|
|
if err := validateOriginAddress(address); err != nil {
|
|
return "", nil, err
|
|
}
|
|
originURL, err := buildOriginURLFromParts(scheme, address, port, uri)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
origin, err := getOrCreateOriginByAddress(address)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return originURL, &origin.ID, nil
|
|
}
|
|
|
|
originURL := strings.TrimSpace(input.OriginURL)
|
|
if originURL == "" {
|
|
return "", nil, errors.New("origin_url cannot be empty")
|
|
}
|
|
address, err := extractOriginAddress(originURL)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
origin, findErr := model.GetOriginByAddress(address)
|
|
if findErr == nil {
|
|
return originURL, &origin.ID, nil
|
|
}
|
|
if !errors.Is(findErr, gorm.ErrRecordNotFound) {
|
|
return "", nil, findErr
|
|
}
|
|
return originURL, nil, nil
|
|
}
|
|
|
|
func hasStructuredOriginInput(input ProxyRouteInput) bool {
|
|
return (input.OriginID != nil && *input.OriginID != 0) ||
|
|
strings.TrimSpace(input.OriginScheme) != "" ||
|
|
strings.TrimSpace(input.OriginAddress) != "" ||
|
|
strings.TrimSpace(input.OriginPort) != "" ||
|
|
strings.TrimSpace(input.OriginURI) != ""
|
|
}
|
|
|
|
func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRouteCustomHeaderInput, error) {
|
|
if len(headers) == 0 {
|
|
return []ProxyRouteCustomHeaderInput{}, nil
|
|
}
|
|
normalized := make([]ProxyRouteCustomHeaderInput, 0, len(headers))
|
|
for _, header := range headers {
|
|
key := strings.TrimSpace(header.Key)
|
|
value := strings.TrimSpace(header.Value)
|
|
if key == "" && value == "" {
|
|
continue
|
|
}
|
|
if key == "" {
|
|
return nil, errors.New("custom header key cannot be empty")
|
|
}
|
|
if !proxyHeaderKeyPattern.MatchString(key) {
|
|
return nil, errors.New("custom header key format is invalid")
|
|
}
|
|
if strings.ContainsAny(key, "\r\n") || strings.ContainsAny(value, "\r\n") {
|
|
return nil, errors.New("custom headers cannot contain newlines")
|
|
}
|
|
normalized = append(normalized, ProxyRouteCustomHeaderInput{
|
|
Key: key,
|
|
Value: value,
|
|
})
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func normalizeUpstreams(originURL string, upstreams []string) ([]string, error) {
|
|
candidates := make([]string, 0, len(upstreams)+1)
|
|
if strings.TrimSpace(originURL) != "" {
|
|
candidates = append(candidates, originURL)
|
|
}
|
|
candidates = append(candidates, upstreams...)
|
|
trimmed := make([]string, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
item := strings.TrimSpace(candidate)
|
|
if item == "" {
|
|
continue
|
|
}
|
|
trimmed = append(trimmed, item)
|
|
}
|
|
unique := make([]string, 0, len(trimmed))
|
|
seen := make(map[string]struct{}, len(trimmed))
|
|
for _, item := range trimmed {
|
|
if _, ok := seen[item]; ok {
|
|
continue
|
|
}
|
|
seen[item] = struct{}{}
|
|
unique = append(unique, item)
|
|
}
|
|
normalized := make([]string, 0, len(unique))
|
|
var scheme string
|
|
multiUpstream := len(unique) > 1
|
|
for _, item := range unique {
|
|
if err := validateOriginURL(item); err != nil {
|
|
return nil, err
|
|
}
|
|
parsed, err := url.ParseRequestURI(item)
|
|
if err != nil {
|
|
return nil, errors.New("origin URL format is invalid")
|
|
}
|
|
if multiUpstream && parsed.Path != "" && parsed.Path != "/" {
|
|
return nil, errors.New("multi-upstream mode does not support origin paths")
|
|
}
|
|
if multiUpstream && parsed.RawQuery != "" {
|
|
return nil, errors.New("multi-upstream mode does not support origin query strings")
|
|
}
|
|
if scheme == "" {
|
|
scheme = parsed.Scheme
|
|
} else if scheme != parsed.Scheme {
|
|
return nil, errors.New("all upstreams must use the same scheme")
|
|
}
|
|
normalized = append(normalized, item)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil, errors.New("at least one upstream is required")
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error) {
|
|
text := strings.TrimSpace(raw)
|
|
if text == "" {
|
|
return []ProxyRouteCustomHeaderInput{}, nil
|
|
}
|
|
var headers []ProxyRouteCustomHeaderInput
|
|
if err := json.Unmarshal([]byte(text), &headers); err != nil {
|
|
return nil, errors.New("custom_headers payload is invalid")
|
|
}
|
|
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("cache policy is not supported")
|
|
}
|
|
}
|
|
|
|
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("cache suffix format is invalid")
|
|
}
|
|
if _, ok := seen[item]; ok {
|
|
continue
|
|
}
|
|
seen[item] = struct{}{}
|
|
normalized = append(normalized, item)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil, errors.New("at least one suffix is required")
|
|
}
|
|
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("cache path rule format is invalid")
|
|
}
|
|
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("at least one path prefix is required")
|
|
}
|
|
return nil, errors.New("at least one exact path is required")
|
|
}
|
|
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("cache_rules payload is invalid")
|
|
}
|
|
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 decodeStoredUpstreams(raw string, fallbackOriginURL string) ([]string, error) {
|
|
text := strings.TrimSpace(raw)
|
|
if text == "" {
|
|
return normalizeUpstreams(fallbackOriginURL, nil)
|
|
}
|
|
var upstreams []string
|
|
if err := json.Unmarshal([]byte(text), &upstreams); err != nil {
|
|
return nil, errors.New("upstreams payload is invalid")
|
|
}
|
|
return normalizeUpstreams(fallbackOriginURL, upstreams)
|
|
}
|
|
|
|
func decodeStoredDomains(raw string, fallbackDomain string) ([]string, error) {
|
|
text := strings.TrimSpace(raw)
|
|
if text == "" {
|
|
return normalizeProxyRouteDomains([]string{fallbackDomain})
|
|
}
|
|
var domains []string
|
|
if err := json.Unmarshal([]byte(text), &domains); err != nil {
|
|
return nil, errors.New("domains payload is invalid")
|
|
}
|
|
return normalizeProxyRouteDomains(domains)
|
|
}
|
|
|
|
func validateOriginURL(raw string) error {
|
|
if raw == "" {
|
|
return errors.New("origin URL cannot be empty")
|
|
}
|
|
parsed, err := url.ParseRequestURI(raw)
|
|
if err != nil {
|
|
return errors.New("origin URL format is invalid")
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return errors.New("origin URL must start with http:// or https://")
|
|
}
|
|
if parsed.Host == "" {
|
|
return errors.New("origin URL format is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateOriginHost(raw string) error {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
if strings.ContainsAny(raw, "/\\ \t\r\n") || strings.Contains(raw, "://") {
|
|
return errors.New("origin_host format is invalid")
|
|
}
|
|
parsed, err := url.Parse("//" + raw)
|
|
if err != nil || parsed.Host == "" || parsed.Host != raw {
|
|
return errors.New("origin_host format is invalid")
|
|
}
|
|
if parsed.Hostname() == "" {
|
|
return errors.New("origin_host format is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isUniqueConstraintError(err error) bool {
|
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique")
|
|
}
|