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(), "
+
-