diff --git a/app/src/views/site/site_edit/components/SiteEditor/store.ts b/app/src/views/site/site_edit/components/SiteEditor/store.ts index e45a4f6d..621293cd 100644 --- a/app/src/views/site/site_edit/components/SiteEditor/store.ts +++ b/app/src/views/site/site_edit/components/SiteEditor/store.ts @@ -68,6 +68,15 @@ export const useSiteEditorStore = defineStore('siteEditor', () => { await buildConfig() } + if (data.value.sync_node_ids === null) { + data.value.sync_node_ids = [] + } + + // @ts-expect-error allow comparing with empty string for legacy data + if (data.value.namespace_id === '') { + data.value.namespace_id = 0 + } + const response = await site.updateItem(encodeURIComponent(name.value), { content: configText.value, overwrite: true, diff --git a/app/src/views/stream/store.ts b/app/src/views/stream/store.ts index 39f7e1d8..0fbbde05 100644 --- a/app/src/views/stream/store.ts +++ b/app/src/views/stream/store.ts @@ -56,6 +56,15 @@ export const useStreamEditorStore = defineStore('streamEditor', () => { await buildConfig() } + if (data.value.sync_node_ids === null) { + data.value.sync_node_ids = [] + } + + // @ts-expect-error allow comparing with empty string for legacy data + if (data.value.namespace_id === '') { + data.value.namespace_id = 0 + } + const response = await stream.updateItem(encodeURIComponent(name.value), { content: configText.value, overwrite: true, diff --git a/internal/upstream/upstream_parser.go b/internal/upstream/upstream_parser.go index 555668b6..aea05bfa 100644 --- a/internal/upstream/upstream_parser.go +++ b/internal/upstream/upstream_parser.go @@ -42,6 +42,7 @@ func ParseProxyTargetsAndUpstreamsFromRawContent(content string) *ParseResult { upstreams := make(map[string][]ProxyTarget) // First, collect all upstream names and their contexts + // Also collect literal variable assignments from `set $var value;` upstreamNames := make(map[string]bool) upstreamContexts := make(map[string]*TheUpstreamContext) upstreamRegex := regexp.MustCompile(`(?s)upstream\s+([^\s]+)\s*\{([^}]+)\}`) @@ -92,13 +93,24 @@ func ParseProxyTargetsAndUpstreamsFromRawContent(content string) *ParseResult { } } + // Collect simple literal variables defined via `set $var value;` + // Only variables with literal values (no nginx variables inside) are recorded. + variableValues := extractLiteralSetVariables(content) + // Parse proxy_pass directives, but skip upstream references proxyPassRegex := regexp.MustCompile(`(?m)^\s*proxy_pass\s+([^;]+);`) proxyMatches := proxyPassRegex.FindAllStringSubmatch(content, -1) for _, match := range proxyMatches { if len(match) >= 2 { - proxyPassURL := strings.TrimSpace(match[1]) + rawValue := strings.TrimSpace(match[1]) + + // If the value is a single variable like `$target`, try to resolve it from `set $target ...;` + if resolved, ok := resolveSingleVariable(rawValue, variableValues); ok { + rawValue = resolved + } + + proxyPassURL := rawValue // Skip if this proxy_pass references an upstream if !isUpstreamReference(proxyPassURL, upstreamNames) { target := parseProxyPassURL(proxyPassURL, "proxy_pass") @@ -115,7 +127,14 @@ func ParseProxyTargetsAndUpstreamsFromRawContent(content string) *ParseResult { for _, match := range grpcMatches { if len(match) >= 2 { - grpcPassURL := strings.TrimSpace(match[1]) + rawValue := strings.TrimSpace(match[1]) + + // If the value is a single variable like `$target`, try to resolve it from `set $target ...;` + if resolved, ok := resolveSingleVariable(rawValue, variableValues); ok { + rawValue = resolved + } + + grpcPassURL := rawValue // Skip if this grpc_pass references an upstream if !isUpstreamReference(grpcPassURL, upstreamNames) { target := parseProxyPassURL(grpcPassURL, "grpc_pass") @@ -391,3 +410,63 @@ func isUpstreamReference(passURL string, upstreamNames map[string]bool) bool { return false } + +// extractLiteralSetVariables parses `set $var value;` directives from the entire content and +// returns a map of variable name to its literal value. Values containing nginx variables are ignored. +func extractLiteralSetVariables(content string) map[string]string { + result := make(map[string]string) + + // Capture variable name and raw value (without trailing semicolon) + setRegex := regexp.MustCompile(`(?m)^\s*set\s+\$([A-Za-z0-9_]+)\s+([^;]+);`) + matches := setRegex.FindAllStringSubmatch(content, -1) + for _, m := range matches { + if len(m) < 3 { + continue + } + name := m[1] + value := strings.TrimSpace(m[2]) + + // Remove surrounding quotes if any + if len(value) >= 2 { + if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) || + (strings.HasPrefix(value, `'`) && strings.HasSuffix(value, `'`)) { + value = strings.Trim(value, `"'`) + } + } + + // Ignore values containing nginx variables unless it is a single variable reference + if strings.Contains(value, "$") { + // Support simple indirection: set $a $b; + if resolved, ok := resolveSingleVariable(value, result); ok { + result[name] = resolved + } + continue + } + + // Record literal value + result[name] = value + } + return result +} + +// resolveSingleVariable resolves an expression that is exactly a single variable like `$target` +// using the provided map. Returns (resolvedValue, true) if resolvable; otherwise ("", false). +func resolveSingleVariable(expr string, variables map[string]string) (string, bool) { + expr = strings.TrimSpace(expr) + // Match exactly `$varName` with optional surrounding spaces + varOnlyRegex := regexp.MustCompile(`^\$([A-Za-z0-9_]+)$`) + sub := varOnlyRegex.FindStringSubmatch(expr) + if len(sub) < 2 { + return "", false + } + name := sub[1] + val, ok := variables[name] + if !ok { + return "", false + } + // Guard against cyclic or unresolved values that still contain variables + if strings.Contains(val, "$") { + return "", false + } + return val, true +} diff --git a/internal/upstream/upstream_parser_test.go b/internal/upstream/upstream_parser_test.go index 58ebaca2..bee02a17 100644 --- a/internal/upstream/upstream_parser_test.go +++ b/internal/upstream/upstream_parser_test.go @@ -753,3 +753,155 @@ func TestGrpcPassPortDefaults(t *testing.T) { }) } } + +// New tests covering `set $var ...;` with proxy_pass/grpc_pass +func TestSetVariableProxyPass_HTTP(t *testing.T) { + config := ` +server { + listen 80; + set $target http://example.com; + location / { + proxy_pass $target; + } +}` + + targets := ParseProxyTargetsFromRawContent(config) + + expected := ProxyTarget{Host: "example.com", Port: "80", Type: "proxy_pass"} + if len(targets) != 1 { + t.Fatalf("Expected 1 target, got %d", len(targets)) + } + got := targets[0] + if got.Host != expected.Host || got.Port != expected.Port || got.Type != expected.Type { + t.Errorf("Unexpected target: got=%+v expected=%+v", got, expected) + } +} + +func TestSetVariableProxyPass_HTTPS(t *testing.T) { + config := ` +server { + listen 80; + set $target https://example.com; + location / { + proxy_pass $target; + } +}` + + targets := ParseProxyTargetsFromRawContent(config) + + expected := ProxyTarget{Host: "example.com", Port: "443", Type: "proxy_pass"} + if len(targets) != 1 { + t.Fatalf("Expected 1 target, got %d", len(targets)) + } + got := targets[0] + if got.Host != expected.Host || got.Port != expected.Port || got.Type != expected.Type { + t.Errorf("Unexpected target: got=%+v expected=%+v", got, expected) + } +} + +func TestSetVariableProxyPass_QuotedValue(t *testing.T) { + config := ` +server { + listen 80; + set $target "http://example.com:9090"; + location / { + proxy_pass $target; + } +}` + + targets := ParseProxyTargetsFromRawContent(config) + + expected := ProxyTarget{Host: "example.com", Port: "9090", Type: "proxy_pass"} + if len(targets) != 1 { + t.Fatalf("Expected 1 target, got %d", len(targets)) + } + got := targets[0] + if got.Host != expected.Host || got.Port != expected.Port || got.Type != expected.Type { + t.Errorf("Unexpected target: got=%+v expected=%+v", got, expected) + } +} + +func TestSetVariableProxyPass_UnresolvableIgnored(t *testing.T) { + config := ` +server { + listen 80; + set $target http://example.com$request_uri; + location / { + proxy_pass $target; + } +}` + + targets := ParseProxyTargetsFromRawContent(config) + + // Because the variable value contains nginx variables, it should be ignored + if len(targets) != 0 { + t.Errorf("Expected 0 targets, got %d", len(targets)) + for i, target := range targets { + t.Logf("Target %d: %+v", i, target) + } + } +} + +func TestSetVariableProxyPass_UpstreamReferenceIgnored(t *testing.T) { + config := ` +upstream api-1 { + server 127.0.0.1:9000; + keepalive 16; +} +server { + listen 80; + set $target http://api-1/; + location / { + proxy_pass $target; + } +}` + + targets := ParseProxyTargetsFromRawContent(config) + + // Expect only upstream servers, and proxy_pass via $target should be ignored + expectedTargets := []ProxyTarget{ + {Host: "127.0.0.1", Port: "9000", Type: "upstream"}, + } + + if len(targets) != len(expectedTargets) { + t.Errorf("Expected %d targets, got %d", len(expectedTargets), len(targets)) + for i, target := range targets { + t.Logf("Target %d: %+v", i, target) + } + return + } + + targetMap := make(map[string]ProxyTarget) + for _, target := range targets { + key := formatSocketAddress(target.Host, target.Port) + ":" + target.Type + targetMap[key] = target + } + for _, expected := range expectedTargets { + key := formatSocketAddress(expected.Host, expected.Port) + ":" + expected.Type + if _, found := targetMap[key]; !found { + t.Errorf("Expected target not found: %+v", expected) + } + } +} + +func TestSetVariableGrpcPass(t *testing.T) { + config := ` +server { + listen 80 http2; + set $g grpc://127.0.0.1:9090; + location /svc/ { + grpc_pass $g; + } +}` + + targets := ParseProxyTargetsFromRawContent(config) + + expected := ProxyTarget{Host: "127.0.0.1", Port: "9090", Type: "grpc_pass"} + if len(targets) != 1 { + t.Fatalf("Expected 1 target, got %d", len(targets)) + } + got := targets[0] + if got.Host != expected.Host || got.Port != expected.Port || got.Type != expected.Type { + t.Errorf("Unexpected target: got=%+v expected=%+v", got, expected) + } +}