diff --git a/integrations/slack-gateway/.env.example b/integrations/slack-gateway/.env.example index bd7a22e..06347bc 100644 --- a/integrations/slack-gateway/.env.example +++ b/integrations/slack-gateway/.env.example @@ -21,6 +21,9 @@ SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL=https://backend-fastapi.example.com SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN=fill-me SPRITZ_SLACK_SPRITZ_BASE_URL=https://spritz.example.com +# Optional public React UI base URL for browser redirects. Keep SpritzBaseURL +# private/internal when the gateway should call the Spritz API inside a cluster. +SPRITZ_SLACK_REACT_BASE_URL=https://spritz.example.com SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN=fill-me SPRITZ_SLACK_PRINCIPAL_ID=slack-shared-app diff --git a/integrations/slack-gateway/config.go b/integrations/slack-gateway/config.go index 5a17567..df5f28d 100644 --- a/integrations/slack-gateway/config.go +++ b/integrations/slack-gateway/config.go @@ -24,6 +24,7 @@ type config struct { BackendFastAPIBaseURL string BackendInternalToken string SpritzBaseURL string + ReactBaseURL string SpritzServiceToken string PrincipalID string HTTPTimeout time.Duration @@ -55,6 +56,7 @@ func loadConfig() (config, error) { BackendFastAPIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL")), "/"), BackendInternalToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN")), SpritzBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_BASE_URL")), "/"), + ReactBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_REACT_BASE_URL")), "/"), SpritzServiceToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN")), PrincipalID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_PRINCIPAL_ID")), HTTPTimeout: parseDurationEnv("SPRITZ_SLACK_HTTP_TIMEOUT", 15*time.Second), @@ -103,6 +105,16 @@ func loadConfig() (config, error) { if cfg.SpritzBaseURL == "" { return config{}, fmt.Errorf("SPRITZ_SLACK_SPRITZ_BASE_URL is required") } + if cfg.ReactBaseURL == "" { + cfg.ReactBaseURL = defaultReactBaseURL(cfg.PublicURL, cfg.SpritzBaseURL) + } + reactURL, err := url.Parse(cfg.ReactBaseURL) + if err != nil { + return config{}, fmt.Errorf("SPRITZ_SLACK_REACT_BASE_URL is invalid: %w", err) + } + if strings.TrimSpace(reactURL.Scheme) == "" || strings.TrimSpace(reactURL.Host) == "" { + return config{}, fmt.Errorf("SPRITZ_SLACK_REACT_BASE_URL must be an absolute URL") + } if cfg.SpritzServiceToken == "" { return config{}, fmt.Errorf("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN is required") } @@ -112,6 +124,46 @@ func loadConfig() (config, error) { return cfg, nil } +func defaultReactBaseURL(publicURL string, spritzBaseURL string) string { + spritzBaseURL = strings.TrimRight(strings.TrimSpace(spritzBaseURL), "/") + if !isPrivateServiceBaseURL(spritzBaseURL) { + return spritzBaseURL + } + if publicReactURL := reactBaseURLFromGatewayPublicURL(publicURL); publicReactURL != "" { + return publicReactURL + } + return spritzBaseURL +} + +func reactBaseURLFromGatewayPublicURL(raw string) string { + parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(raw), "/")) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + path := strings.TrimRight(parsed.Path, "/") + if strings.HasSuffix(path, "/slack-gateway") { + path = strings.TrimSuffix(path, "/slack-gateway") + } else { + path = "" + } + parsed.RawPath = "" + parsed.Path = path + parsed.RawQuery = "" + parsed.Fragment = "" + return strings.TrimRight(parsed.String(), "/") +} + +func isPrivateServiceBaseURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return false + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + return strings.HasSuffix(host, ".svc") || + strings.Contains(host, ".svc.") || + strings.HasSuffix(host, ".cluster.local") +} + func envOrDefault(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value diff --git a/integrations/slack-gateway/gateway_test.go b/integrations/slack-gateway/gateway_test.go index 4fa0a6b..8513379 100644 --- a/integrations/slack-gateway/gateway_test.go +++ b/integrations/slack-gateway/gateway_test.go @@ -7990,6 +7990,52 @@ func TestLoadConfigDefaultsBackendFastAPIBaseURLToBackendBaseURL(t *testing.T) { } } +func TestLoadConfigDefaultsReactBaseURLToPublicOriginWhenSpritzBaseURLIsClusterInternal(t *testing.T) { + t.Setenv("SPRITZ_SLACK_GATEWAY_PUBLIC_URL", "https://staging.spritz.textcortex.com/slack-gateway") + t.Setenv("SPRITZ_SLACK_CLIENT_ID", "client-id") + t.Setenv("SPRITZ_SLACK_CLIENT_SECRET", "client-secret") + t.Setenv("SPRITZ_SLACK_SIGNING_SECRET", "signing-secret") + t.Setenv("SPRITZ_SLACK_OAUTH_STATE_SECRET", "oauth-state-secret") + t.Setenv("SPRITZ_SLACK_BACKEND_BASE_URL", "https://backend.example.test") + t.Setenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN", "backend-internal-token") + t.Setenv("SPRITZ_SLACK_SPRITZ_BASE_URL", "http://spritz-api.spritz-system-staging.svc.cluster.local:8080") + t.Setenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN", "spritz-service-token") + t.Setenv("SPRITZ_SLACK_PRINCIPAL_ID", "shared-slack-gateway") + + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig failed: %v", err) + } + if cfg.ReactBaseURL != "https://staging.spritz.textcortex.com" { + t.Fatalf("expected public React base URL, got %q", cfg.ReactBaseURL) + } + if cfg.SpritzBaseURL != "http://spritz-api.spritz-system-staging.svc.cluster.local:8080" { + t.Fatalf("expected internal Spritz base URL to stay unchanged, got %q", cfg.SpritzBaseURL) + } +} + +func TestLoadConfigUsesExplicitReactBaseURL(t *testing.T) { + t.Setenv("SPRITZ_SLACK_GATEWAY_PUBLIC_URL", "https://gateway.example.test/slack-gateway") + t.Setenv("SPRITZ_SLACK_CLIENT_ID", "client-id") + t.Setenv("SPRITZ_SLACK_CLIENT_SECRET", "client-secret") + t.Setenv("SPRITZ_SLACK_SIGNING_SECRET", "signing-secret") + t.Setenv("SPRITZ_SLACK_OAUTH_STATE_SECRET", "oauth-state-secret") + t.Setenv("SPRITZ_SLACK_BACKEND_BASE_URL", "https://backend.example.test") + t.Setenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN", "backend-internal-token") + t.Setenv("SPRITZ_SLACK_SPRITZ_BASE_URL", "http://spritz-api.spritz-system-staging.svc.cluster.local:8080") + t.Setenv("SPRITZ_SLACK_REACT_BASE_URL", "https://spritz.example.test/app") + t.Setenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN", "spritz-service-token") + t.Setenv("SPRITZ_SLACK_PRINCIPAL_ID", "shared-slack-gateway") + + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig failed: %v", err) + } + if cfg.ReactBaseURL != "https://spritz.example.test/app" { + t.Fatalf("expected explicit React base URL, got %q", cfg.ReactBaseURL) + } +} + func TestSpritzWebSocketURLPreservesBasePath(t *testing.T) { gateway := newSlackGateway( config{SpritzBaseURL: "https://spritz.example.test/prefix"}, @@ -8037,6 +8083,21 @@ func TestReactRouteURLUsesSpritzBaseURL(t *testing.T) { } } +func TestReactRouteURLUsesReactBaseURL(t *testing.T) { + gateway := newSlackGateway( + config{ + SpritzBaseURL: "http://spritz-api.spritz-system-staging.svc.cluster.local:8080", + ReactBaseURL: "https://staging.spritz.textcortex.com", + }, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + + target := gateway.reactRouteURL("/settings/slack/workspaces") + if target != "https://staging.spritz.textcortex.com/settings/slack/workspaces" { + t.Fatalf("expected public React redirect URL, got %q", target) + } +} + func TestReactRoutesShareGatewayOrigin(t *testing.T) { sameOrigin := newSlackGateway( config{ diff --git a/integrations/slack-gateway/react_routes.go b/integrations/slack-gateway/react_routes.go index 4352a6d..ee45a7d 100644 --- a/integrations/slack-gateway/react_routes.go +++ b/integrations/slack-gateway/react_routes.go @@ -54,7 +54,7 @@ func (g *slackGateway) reactRouteURL(target string) string { return route.String() } - base, err := url.Parse(strings.TrimRight(strings.TrimSpace(g.cfg.SpritzBaseURL), "/")) + base, err := url.Parse(strings.TrimRight(strings.TrimSpace(g.reactBaseURL()), "/")) if err != nil || base.Scheme == "" || base.Host == "" { return route.String() } @@ -67,12 +67,19 @@ func (g *slackGateway) reactRouteURL(target string) string { return base.String() } +func (g *slackGateway) reactBaseURL() string { + if baseURL := strings.TrimSpace(g.cfg.ReactBaseURL); baseURL != "" { + return baseURL + } + return g.cfg.SpritzBaseURL +} + func (g *slackGateway) reactRoutesShareGatewayOrigin() bool { gatewayURL, err := url.Parse(strings.TrimSpace(g.cfg.PublicURL)) if err != nil || gatewayURL.Scheme == "" || gatewayURL.Host == "" { return false } - reactURL, err := url.Parse(strings.TrimSpace(g.cfg.SpritzBaseURL)) + reactURL, err := url.Parse(strings.TrimSpace(g.reactBaseURL())) if err != nil || reactURL.Scheme == "" || reactURL.Host == "" { return false }