diff --git a/backend/internal/middleware/security_headers_test.go b/backend/internal/middleware/security_headers_test.go index 26020c32..096fa47b 100644 --- a/backend/internal/middleware/security_headers_test.go +++ b/backend/internal/middleware/security_headers_test.go @@ -12,7 +12,7 @@ import ( func TestSecurityHeaders_Default(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - r.Use(SecurityHeaders(SecurityHeadersConfig{})) + r.Use(SecurityHeaders(DefaultSecurityHeadersConfig())) r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) @@ -21,6 +21,8 @@ func TestSecurityHeaders_Default(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) + assert.Contains(t, w.Header().Get("Content-Security-Policy"), "script-src 'self'") + assert.NotContains(t, w.Header().Get("Content-Security-Policy"), "script-src 'self' 'unsafe-inline'") } func TestSecurityHeaders_WithHSTS(t *testing.T) { @@ -56,4 +58,4 @@ func TestSecurityHeaders_WithCSP(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) -} \ No newline at end of file +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 476b918e..c8a445ca 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "path/filepath" "strconv" "strings" "time" @@ -225,8 +226,10 @@ func RegisterRoutes( // Dashboard shell routes used by Chatwoot mailer and push deep links. // Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index. + engine.GET("/runtime-config.js", dashboardRuntimeConfig) engine.GET("/app", dashboardIndex) engine.GET("/app/*params", dashboardIndex) + engine.NoRoute(dashboardStatic) // Auth routes — PUBLIC, no AuthRequired middleware v1.RegisterAuthRoutes(engine.Group("/api/v1"), handlers.Auth) @@ -2243,14 +2246,7 @@ func dashboardIndex(c *gin.Context) { return } - installationName := strings.TrimSpace(os.Getenv("INSTALLATION_NAME")) - if installationName == "" { - installationName = "GoChat" - } - frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") - helpCenterURL := strings.TrimRight(os.Getenv("HELPCENTER_URL"), "/") - - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(dashboardHTML(installationName, frontendURL, helpCenterURL))) + c.File(filepath.Join(frontendDistDir(), "index.html")) } func dashboardWantsJSON(c *gin.Context) bool { @@ -2261,31 +2257,36 @@ func dashboardWantsJSON(c *gin.Context) bool { return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html") } -func dashboardHTML(installationName string, frontendURL string, helpCenterURL string) string { - name := html.EscapeString(installationName) - chatwootConfig := dashboardJSON(map[string]any{ - "hostURL": frontendURL, - "helpCenterURL": helpCenterURL, +func dashboardRuntimeConfig(c *gin.Context) { + installationName := strings.TrimSpace(os.Getenv("INSTALLATION_NAME")) + if installationName == "" { + installationName = "GoChat" + } + config := dashboardJSON(map[string]any{ + "hostURL": strings.TrimRight(os.Getenv("FRONTEND_URL"), "/"), + "helpCenterURL": strings.TrimRight(os.Getenv("HELPCENTER_URL"), "/"), "allowedLoginMethods": []string{"email"}, "signupEnabled": "false", "selectedLocale": "en", + "globalConfig": map[string]any{"INSTALLATION_NAME": installationName}, }) - globalConfig := dashboardJSON(map[string]any{"INSTALLATION_NAME": installationName}) - return ` - - - ` + name + ` - - - - -
- - -` + c.Header("Cache-Control", "no-store") + c.Data(http.StatusOK, "application/javascript; charset=utf-8", []byte("window.__GOCHAT_CONFIG__ = "+config+";\n")) +} + +func dashboardStatic(c *gin.Context) { + if c.Request.URL.Path == "/" { + c.Status(http.StatusNotFound) + return + } + http.FileServer(http.Dir(frontendDistDir())).ServeHTTP(c.Writer, c.Request) +} + +func frontendDistDir() string { + if dir := strings.TrimSpace(os.Getenv("GOCHAT_FRONTEND_DIST")); dir != "" { + return dir + } + return "/app/frontend/dist" } func dashboardJSON(value any) string { diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 70016bec..6a834f24 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -7,6 +7,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" @@ -61,6 +63,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /microsoft/callback", "GET /instagram/callback", "GET /tiktok/callback", + "GET /runtime-config.js", "GET /app", "GET /app/*params", "GET /api/v1/connector/shangwutong/inboxes", @@ -868,13 +871,26 @@ func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) { func TestDashboardIndexServesChatwootShell(t *testing.T) { gin.SetMode(gin.TestMode) + dist := t.TempDir() + if err := os.Mkdir(filepath.Join(dist, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte(`
`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dist, "assets", "app.css"), []byte("body{}"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GOCHAT_FRONTEND_DIST", dist) t.Setenv("INSTALLATION_NAME", "GoChat Test") t.Setenv("FRONTEND_URL", "https://app.example.test/") t.Setenv("HELPCENTER_URL", "https://help.example.test/") engine := gin.New() + engine.GET("/runtime-config.js", dashboardRuntimeConfig) engine.GET("/app", dashboardIndex) engine.GET("/app/*params", dashboardIndex) + engine.NoRoute(dashboardStatic) recorder := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/app/accounts/1/conversations/42", nil) @@ -888,12 +904,25 @@ func TestDashboardIndexServesChatwootShell(t *testing.T) { if !strings.Contains(body, `
`) { t.Fatalf("expected dashboard app mount in response: %s", body) } - if !strings.Contains(body, `"hostURL":"https://app.example.test"`) { - t.Fatalf("expected frontend URL in chatwoot config: %s", body) + if !strings.Contains(body, `src="/runtime-config.js"`) || !strings.Contains(body, `href="/assets/app.css"`) { + t.Fatalf("expected external config and built asset links: %s", body) } - enterpriseModeKey := "is" + "Enterprise" - if strings.Contains(body, enterpriseModeKey) { - t.Fatalf("dashboard config must not expose enterprise mode: %s", body) + + runtimeConfig := performGet(engine, "/runtime-config.js") + if runtimeConfig.Code != http.StatusOK || !strings.Contains(runtimeConfig.Header().Get("Content-Type"), "application/javascript") { + t.Fatalf("expected JavaScript runtime config, got %d %q", runtimeConfig.Code, runtimeConfig.Header().Get("Content-Type")) + } + if !strings.Contains(runtimeConfig.Body.String(), `"hostURL":"https://app.example.test"`) || + !strings.Contains(runtimeConfig.Body.String(), `"INSTALLATION_NAME":"GoChat Test"`) { + t.Fatalf("expected environment-backed runtime config: %s", runtimeConfig.Body.String()) + } + if strings.Contains(runtimeConfig.Body.String(), " cookie.startsWith('cw_d_session_info=')); + if (sessionCookie) { + const raw = decodeURIComponent(sessionCookie.split('=').slice(1).join('=')); + const extract = key => + raw.match(new RegExp(`"${key}":"([^"]+)"`))?.[1] || ''; + const token = extract('access-token'); + if (token) { + localStorage.setItem('access-token', token); + localStorage.setItem('client', extract('client')); + localStorage.setItem('uid', extract('uid')); + localStorage.setItem('token-type', extract('token-type') || 'Bearer'); + localStorage.setItem('expiry', extract('expiry')); + } + } +} catch { + // Ignore malformed legacy session cookies. +} diff --git a/frontend/index.html b/frontend/index.html index 0f73cd49..9eb10c84 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,93 +5,11 @@ GoChat - +
- diff --git a/frontend/package.json b/frontend/package.json index 87878dea..0df45051 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "test:build": "vite build && node scripts/check-build.mjs", "build:sdk": "BUILD_MODE=library vite build", "preview": "vite preview", "eslint": "eslint app/**/*.{js,vue}", diff --git a/frontend/scripts/check-build.mjs b/frontend/scripts/check-build.mjs new file mode 100644 index 00000000..e3be6362 --- /dev/null +++ b/frontend/scripts/check-build.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const dist = fileURLToPath(new URL('../dist/', import.meta.url)); +const html = readFileSync( + new URL('../dist/index.html', import.meta.url), + 'utf8' +); +const assets = [...html.matchAll(/(?:href|src)="(\/assets\/[^"]+)"/g)].map( + match => match[1] +); + +assert.match(html, /
<\/div>/); +assert.match(html, /