H-162: serve built frontend from Go image (#34)
Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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));
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user