H-162: serve built frontend from Go image (#34)

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-15 20:49:26 +08:00
committed by GitHub
co-authored by rogee
parent e61f5b252f
commit 21a9a6793d
10 changed files with 183 additions and 122 deletions
@@ -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)
}
}
+30 -29
View File
@@ -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 `<!DOCTYPE html>
<html>
<head>
<title>` + name + `</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=0">
<script>
window.chatwootConfig = ` + chatwootConfig + `;
window.globalConfig = ` + globalConfig + `;
</script>
</head>
<body class="text-slate-600">
<div id="app"></div>
<noscript id="noscript">This app works best with JavaScript enabled.</noscript>
</body>
</html>`
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 {
+34 -5
View File
@@ -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(`<div id="app"></div><link rel="stylesheet" href="/assets/app.css"><script src="/runtime-config.js"></script><script type="module" src="/assets/app.js"></script>`), 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, `<div id="app"></div>`) {
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(), "<script") {
t.Fatalf("runtime config must be external JavaScript, got %s", runtimeConfig.Body.String())
}
asset := performGet(engine, "/assets/app.css")
if asset.Code != http.StatusOK || asset.Body.String() != "body{}" {
t.Fatalf("expected built asset, got %d %q", asset.Code, asset.Body.String())
}
}
+17 -2
View File
@@ -1,7 +1,19 @@
# GoChat Dockerfile — Multi-stage build with security hardening
# Reference: Chatwoot docker/Dockerfile multi-stage pattern + security best practices
# ========== Build Stage ==========
# ========== Frontend Build Stage ==========
FROM node:22-alpine AS frontend-builder
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/package.json frontend/package.json
RUN corepack enable && pnpm install --frozen-lockfile
COPY frontend/ frontend/
RUN pnpm --dir frontend test:build
# ========== Go Build Stage ==========
FROM golang:1.24-alpine AS builder
# Build arguments for version injection
@@ -49,11 +61,14 @@ WORKDIR /app
# Copy binary and configs from builder
COPY --from=builder /gochat /app/gochat
COPY --from=builder /gochat-worker /app/gochat-worker
COPY --chown=gochat:gochat --from=frontend-builder /app/frontend/dist /app/frontend/dist
COPY backend/configs/ /app/configs/
COPY backend/migrations/ /app/migrations/
ENV GOCHAT_FRONTEND_DIST=/app/frontend/dist
# Set ownership to non-root user
RUN mkdir -p /app/storage/uploads && chown -R gochat:gochat /app
RUN mkdir -p /app/storage/uploads && chown -R gochat:gochat /app/storage
# Switch to non-root user for security
USER gochat
@@ -1,3 +1,5 @@
import './dashboardConfig';
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
@@ -0,0 +1,60 @@
const config = window.__GOCHAT_CONFIG__ || {};
window.chatwootConfig = Object.assign(
{
hostURL: '',
helpCenterURL: '',
fbAppId: '',
instagramAppId: '',
tiktokAppId: '',
googleOAuthClientId: '',
googleOAuthCallbackUrl: '',
allowedLoginMethods: ['email'],
fbApiVersion: '',
whatsappAppId: '',
whatsappConfigurationId: '',
whatsappApiVersion: '',
signupEnabled: 'false',
isMfaEnabled: 'false',
inboxEventsEnabled: 'false',
selectedLocale: 'zh_CN',
enabledLanguages: [
{ name: '中文', iso_639_1_code: 'zh_CN' },
{ name: 'English', iso_639_1_code: 'en' },
],
helpUrls: {},
},
config
);
window.globalConfig = Object.assign(
{
INSTALLATION_NAME: 'GoChat',
CREATE_NEW_ACCOUNT_FROM_DASHBOARD: false,
DISPLAY_MANIFEST: false,
LOGO_THUMBNAIL: '/favicon-32x32.png',
},
config.globalConfig || {}
);
window.errorLoggingConfig = '';
window.browserConfig = { browser_name: navigator.userAgent };
try {
const sessionCookie = document.cookie
.split('; ')
.find(cookie => 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.
}
+1 -83
View File
@@ -5,93 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=0" />
<title>GoChat</title>
<link rel="icon" type="image/png" href="/favicon-32x32.png" />
<script>
// GoChat frontend runtime config — injected statically (decoupled from Rails).
// Originally these were rendered by Rails ERB (vueapp.html.erb).
// Override individual keys via window.__GOCHAT_CONFIG__ before this script
// runs, or via VITE_* env vars at build time (see config below).
(function () {
var cfg = window.__GOCHAT_CONFIG__ || {};
window.chatwootConfig = Object.assign(
{
hostURL: '', // API base URL (empty = same origin)
helpCenterURL: '',
fbAppId: '',
instagramAppId: '',
tiktokAppId: '',
googleOAuthClientId: '',
googleOAuthCallbackUrl: '',
allowedLoginMethods: ['email'],
fbApiVersion: '',
whatsappAppId: '',
whatsappConfigurationId: '',
whatsappApiVersion: '',
signupEnabled: 'false',
isMfaEnabled: 'false',
inboxEventsEnabled: 'false',
selectedLocale: 'zh_CN',
enabledLanguages: [
{ name: '中文', iso_639_1_code: 'zh_CN' },
{ name: 'English', iso_639_1_code: 'en' },
],
helpUrls: {},
},
cfg
);
window.globalConfig = Object.assign(
{
INSTALLATION_NAME: 'GoChat',
CREATE_NEW_ACCOUNT_FROM_DASHBOARD: false,
DISPLAY_MANIFEST: false,
LOGO_THUMBNAIL: '/favicon-32x32.png',
},
cfg.globalConfig || {}
);
window.errorLoggingConfig = '';
window.browserConfig = { browser_name: navigator.userAgent };
// BUG-11 mitigation: sync auth tokens from cookie to localStorage on page load
// The backend stores tokens in cw_d_session_info cookie, but the Vue SPA's
// axios interceptor reads from localStorage. This bridge ensures the tokens
// are available in both stores.
(function() {
try {
var cookies = document.cookie.split('; ');
var sessionCookie = cookies.find(function(c) { return c.startsWith('cw_d_session_info='); });
if (sessionCookie) {
var raw = decodeURIComponent(sessionCookie.split('=').slice(1).join('='));
var extract = function(key) {
var match = raw.match(new RegExp('"' + key + '":"([^"]+)"'));
return match ? match[1] : '';
};
var 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(e) { /* ignore cookie parse errors */ }
})();
})();
</script>
<script src="/runtime-config.js"></script>
</head>
<body class="text-slate-600">
<div id="app"></div>
<noscript id="noscript">This app works best with JavaScript enabled.</noscript>
<script type="module">
if (import.meta.env.DEV) {
// ninja-keys intentionally uses Lit. Its generic dev-mode notice is not
// actionable for this Vue application, so keep the console focused on
// application warnings while preserving Lit's other diagnostics.
globalThis.litIssuedWarnings ??= new Set();
globalThis.litIssuedWarnings.add(
'Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information.'
);
}
</script>
<script type="module" src="/app/javascript/entrypoints/dashboard.js"></script>
</body>
</html>
+1
View File
@@ -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}",
+25
View File
@@ -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 id="app"><\/div>/);
assert.match(html, /<script src="\/runtime-config\.js"><\/script>/);
assert.ok(
assets.some(asset => asset.endsWith('.js')),
'missing JS asset'
);
assert.ok(
assets.some(asset => asset.endsWith('.css')),
'missing CSS asset'
);
assert.ok(!/<script(?![^>]*\bsrc=)[^>]*>/i.test(html), 'inline script found');
assets.forEach(asset => assert.ok(existsSync(dist + asset.slice(1)), asset));
+9 -1
View File
@@ -52,7 +52,7 @@ const resolveAliases = {
// Entrypoints that vite-plugin-ruby used to auto-discover
const entrypoints = {
dashboard: path.resolve(__dirname, './app/javascript/entrypoints/dashboard.js'),
dashboard: path.resolve(__dirname, './index.html'),
widget: path.resolve(__dirname, './app/javascript/entrypoints/widget.js'),
portal: path.resolve(__dirname, './app/javascript/entrypoints/portal.js'),
superadmin: path.resolve(__dirname, './app/javascript/entrypoints/superadmin.js'),
@@ -71,6 +71,10 @@ if (isLibraryMode) {
export default defineConfig({
plugins,
define: {
__INTLIFY_JIT_COMPILATION__: true,
regeneratorRuntime: 'globalThis.regeneratorRuntime',
},
css: {
preprocessorOptions: {
scss: {
@@ -122,6 +126,10 @@ export default defineConfig({
target: process.env.VITE_API_HOST || 'http://127.0.0.1:3000',
changeOrigin: true,
},
'/runtime-config.js': {
target: process.env.VITE_API_HOST || 'http://127.0.0.1:3000',
changeOrigin: true,
},
'/widget': {
target: process.env.VITE_API_HOST || 'http://127.0.0.1:3000',
changeOrigin: true,