mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
MUL-8253 feat(web): opt-in Google Analytics via GOOGLE_ANALYTICS_ID (#9544)
* feat(web): opt-in Google Analytics via GOOGLE_ANALYTICS_ID (MUL-8253) Co-authored-by: multica-agent <[email protected]> * fix(i18n): add missing zh-Hant keys for model override and scheduled wakeup Co-authored-by: multica-agent <[email protected]> * fix(web): keep credential-bearing URLs out of Google Analytics Co-authored-by: multica-agent <[email protected]> * feat(selfhost): pass GOOGLE_ANALYTICS_ID through compose and helm Co-authored-by: multica-agent <[email protected]> * fix(web): stop GA for the document after sensitive navigation Co-authored-by: multica-agent <[email protected]> * test(views): await subscription cleanup and dialog focus Co-authored-by: multica-agent <[email protected]> * fix(web): stop GA before credential-bearing link clicks and form submits GA4 enhanced measurement reports outbound/download link_url and form_destination without a URL change, so signed attachment downloads leaked their signature. Stop the tag for the document in a window capture listener before gtag's own listeners observe the interaction. Object URLs are exempt. Adds jsdom tests and a live gtag.js regression covering the ~5s batch delay. Co-authored-by: multica-agent <[email protected]> * test(web): slim GA tests; move views test fixes to #9555 Drop the opt-in live-tag e2e spec (never runs in CI) and condense the GA unit tests to the cases that guard against credential leaks. The two unrelated views test-timing fixes now live in #9555. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> Co-authored-by: Bohan-J <[email protected]>
This commit is contained in:
co-authored by
multica-agent
Bohan-J
parent
9b78e3c309
commit
2213734215
@@ -667,6 +667,11 @@ MULTICA_TELEGRAM_SECRET_KEY=
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_WS_URL=
|
||||
|
||||
# Google Analytics (optional) — GA4 Measurement ID, e.g. G-XXXXXXXXXX.
|
||||
# Leave empty to keep gtag.js off (the default for local dev and self-hosting).
|
||||
# Read by the Next.js server at runtime, so no web rebuild is needed to toggle it.
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
# REMOTE_API_URL=https://multica-api.copilothub.ai
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
resolveBrowserApiBaseUrl,
|
||||
resolveBrowserWsUrl,
|
||||
} from "@/config/runtime-urls";
|
||||
import { resolveGoogleAnalyticsId } from "@/config/google-analytics";
|
||||
import { GoogleAnalytics } from "@/components/google-analytics";
|
||||
import "./globals.css";
|
||||
|
||||
// Inter is the Latin UI face. next/font produces a hashed family (`__Inter_xxx`)
|
||||
@@ -128,6 +130,8 @@ export default async function RootLayout({
|
||||
const resources = { [locale]: RESOURCES[locale] };
|
||||
const apiBaseUrl = resolveBrowserApiBaseUrl(process.env);
|
||||
const wsUrl = resolveBrowserWsUrl(process.env);
|
||||
// Opt-in Google Analytics: omitted entirely unless GOOGLE_ANALYTICS_ID is set.
|
||||
const googleAnalyticsId = resolveGoogleAnalyticsId(process.env);
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -165,6 +169,7 @@ export default async function RootLayout({
|
||||
</WebProviders>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
{googleAnalyticsId && <GoogleAnalytics measurementId={googleAnalyticsId} />}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
|
||||
import { GoogleAnalytics } from "./google-analytics";
|
||||
|
||||
// Guards the privacy gate in lib/google-analytics.ts. A regression here would
|
||||
// not show up as an error; credentials would silently reach Google.
|
||||
const ID = "G-TEST1234";
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const disabled = () => w[`ga-disable-${ID}`];
|
||||
const scripts = () => document.querySelectorAll('script[src*="googletagmanager.com/gtag/js"]').length;
|
||||
const { pushState, replaceState } = window.history;
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(window.history, { pushState, replaceState });
|
||||
for (const k of ["__multicaGoogleAnalytics", `ga-disable-${ID}`, "dataLayer", "gtag"]) delete w[k];
|
||||
document.querySelectorAll("script").forEach((s) => s.remove());
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
const start = (path: string) => {
|
||||
window.history.replaceState(null, "", path);
|
||||
render(<GoogleAnalytics measurementId={ID} />);
|
||||
};
|
||||
|
||||
const click = (href: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
a.addEventListener("click", (e) => e.preventDefault()); // jsdom can't navigate
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
it("loads only once on a clean URL, never on a sensitive landing", () => {
|
||||
start("/wecom/bind?token=secret");
|
||||
expect(scripts()).toBe(0);
|
||||
window.history.pushState(null, "", "/acme/issues");
|
||||
window.history.pushState(null, "", "/acme/projects");
|
||||
expect(scripts()).toBe(1);
|
||||
expect(disabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("stops for the document after visiting a sensitive URL", () => {
|
||||
start("/about");
|
||||
window.history.pushState(null, "", "/auth/callback?code=c");
|
||||
window.history.pushState(null, "", "/privacy");
|
||||
window.history.replaceState(null, "", "/privacy");
|
||||
expect(disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("stops before a signed download click, but not for clean or blob links", () => {
|
||||
start("/about");
|
||||
click("https://github.com/multica-ai/multica");
|
||||
click("blob:https://app.example.invalid/uuid");
|
||||
expect(disabled()).toBe(false);
|
||||
click("https://cdn.example.invalid/a.pdf?Signature=s");
|
||||
expect(disabled()).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { installGoogleAnalytics } from "@/lib/google-analytics";
|
||||
|
||||
export function GoogleAnalytics({ measurementId }: { measurementId: string }) {
|
||||
useEffect(() => {
|
||||
installGoogleAnalytics(measurementId);
|
||||
}, [measurementId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// @vitest-environment node
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { resolveGoogleAnalyticsId, sanitizeAnalyticsUrl } from "./google-analytics";
|
||||
|
||||
it("enables only a valid GA4 measurement ID", () => {
|
||||
expect(resolveGoogleAnalyticsId({})).toBeUndefined();
|
||||
expect(resolveGoogleAnalyticsId({ GOOGLE_ANALYTICS_ID: " g-rqvf209ehd " })).toBe("G-RQVF209EHD");
|
||||
// Interpolated into an inline script, so anything else is rejected.
|
||||
expect(resolveGoogleAnalyticsId({ GOOGLE_ANALYTICS_ID: "G-X'); alert(1); //" })).toBeUndefined();
|
||||
expect(resolveGoogleAnalyticsId({ GOOGLE_ANALYTICS_ID: "UA-12345-1" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips credentials from URLs but keeps campaign params", () => {
|
||||
expect(sanitizeAnalyticsUrl("https://multica.ai/wecom/bind?token=t")).toBe("https://multica.ai/wecom/bind");
|
||||
expect(sanitizeAnalyticsUrl("https://multica.ai/invite/inv_1")).toBe("https://multica.ai/invite/[id]");
|
||||
expect(sanitizeAnalyticsUrl("https://multica.ai/?utm_source=x&code=c")).toBe("https://multica.ai/?utm_source=x");
|
||||
expect(sanitizeAnalyticsUrl("javascript:alert(1)")).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
type RuntimeEnv = Record<string, string | undefined>;
|
||||
|
||||
// GA4 Measurement IDs look like `G-RQVF209EHD`. The value is interpolated
|
||||
// into the gtag.js URL and config call, so anything that doesn't match this
|
||||
// shape is rejected outright rather than escaped.
|
||||
const MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{4,20}$/;
|
||||
|
||||
/**
|
||||
* Resolve the Google Analytics (GA4) Measurement ID from the runtime env.
|
||||
*
|
||||
* Opt-in: returns undefined unless GOOGLE_ANALYTICS_ID is set to a valid
|
||||
* GA4 ID, so local dev and self-hosted images never load gtag.js. It is read
|
||||
* by the Next.js server per request (not a NEXT_PUBLIC_* build-time var), so
|
||||
* the published Docker images carry no ID and operators can toggle it without
|
||||
* a rebuild.
|
||||
*/
|
||||
export function resolveGoogleAnalyticsId(env: RuntimeEnv): string | undefined {
|
||||
const value = env.GOOGLE_ANALYTICS_ID?.trim().toUpperCase();
|
||||
if (!value || !MEASUREMENT_ID_PATTERN.test(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Query params that are safe and useful to report. Everything else is
|
||||
// dropped: the app carries live credentials in the query string — IM bind
|
||||
// tokens (`/wecom/bind?token=…` and friends), OAuth `code`/`state` on
|
||||
// `/auth/callback`, CLI login `cli_callback`/`cli_state`, `next` redirect
|
||||
// targets — and GA would otherwise record the full URL (page_location /
|
||||
// page_referrer). An allowlist keeps new secret-bearing params out by default.
|
||||
const ALLOWED_QUERY_PARAMS = new Set([
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_term",
|
||||
"utm_content",
|
||||
"gclid",
|
||||
]);
|
||||
|
||||
// Path segments that are capabilities rather than page names. Invitation ids
|
||||
// are bearer-ish links, so report the route shape, not the value.
|
||||
const REDACTED_PATH_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/^\/invite\/[^/]+/, "/invite/[id]"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Reduce a URL to what is safe to send to Google Analytics: origin + path
|
||||
* (with capability segments masked) + allowlisted query params. The hash is
|
||||
* always dropped. Returns "" for empty or unparseable input, which GA treats
|
||||
* as "not set".
|
||||
*/
|
||||
export function sanitizeAnalyticsUrl(raw: string | undefined | null): string {
|
||||
const parts = analyticsUrlParts(raw);
|
||||
if (!parts) return "";
|
||||
return parts.sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the raw URL carries something sanitizeAnalyticsUrl would strip
|
||||
* (a non-allowlisted query param or a capability path segment). GA's own
|
||||
* history-change page views read the live URL directly, so the component
|
||||
* stops GA for this document once the loaded tag may have observed one.
|
||||
*/
|
||||
export function isSensitiveAnalyticsUrl(raw: string | undefined | null): boolean {
|
||||
if (!raw) return false;
|
||||
const parts = analyticsUrlParts(raw);
|
||||
return !parts || parts.sensitive;
|
||||
}
|
||||
|
||||
function analyticsUrlParts(
|
||||
raw: string | undefined | null,
|
||||
): { sanitized: string; sensitive: boolean } | null {
|
||||
if (!raw) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
|
||||
let sensitive = false;
|
||||
let pathname = url.pathname;
|
||||
for (const [pattern, replacement] of REDACTED_PATH_PATTERNS) {
|
||||
if (pattern.test(pathname)) {
|
||||
sensitive = true;
|
||||
pathname = pathname.replace(pattern, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
const kept = new URLSearchParams();
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (ALLOWED_QUERY_PARAMS.has(key)) kept.append(key, value);
|
||||
else sensitive = true;
|
||||
});
|
||||
const search = kept.toString();
|
||||
|
||||
return {
|
||||
sanitized: `${url.origin}${pathname}${search ? `?${search}` : ""}`,
|
||||
sensitive,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { isSensitiveAnalyticsUrl, sanitizeAnalyticsUrl } from "../config/google-analytics";
|
||||
|
||||
type Gtag = (...args: unknown[]) => void;
|
||||
type GaWindow = Window & {
|
||||
dataLayer?: unknown[];
|
||||
gtag?: Gtag;
|
||||
__multicaGoogleAnalytics?: string;
|
||||
[disableKey: `ga-disable-${string}`]: boolean | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Google tag (gtag.js) for GA4. Rendered only when the root layout resolved a
|
||||
* Measurement ID from GOOGLE_ANALYTICS_ID (config/google-analytics.ts).
|
||||
*
|
||||
* GA's automatic history events can retain a sensitive referrer after the
|
||||
* browser has left it. Once the tag may have observed a sensitive URL, keep
|
||||
* its documented opt-out flag set for the rest of this document's lifetime.
|
||||
* Re-enabling it on a later history event can release credential-bearing
|
||||
* events, including when the tag's download finished on a sensitive URL.
|
||||
* A fresh document can start again, with its landing referrer sanitized.
|
||||
* The same applies to activating a credential-bearing link or form (e.g. a
|
||||
* signed attachment download), which GA reports without a URL change.
|
||||
*/
|
||||
export function installGoogleAnalytics(measurementId: string) {
|
||||
const w = window as unknown as GaWindow;
|
||||
// Install once per page: the root layout never unmounts, and StrictMode /
|
||||
// Fast Refresh re-runs must not stack history hooks or reload gtag.js.
|
||||
if (w.__multicaGoogleAnalytics === measurementId) return;
|
||||
w.__multicaGoogleAnalytics = measurementId;
|
||||
const disableKey = `ga-disable-${measurementId}` as const;
|
||||
let loaded = false;
|
||||
let stopped = false;
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
w[disableKey] = true;
|
||||
};
|
||||
|
||||
const load = () => {
|
||||
loaded = true;
|
||||
w[disableKey] = false;
|
||||
w.dataLayer = w.dataLayer || [];
|
||||
w.gtag = function gtag() {
|
||||
// gtag.js consumes the `arguments` objects queued here (Google's shim).
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
w.dataLayer!.push(arguments);
|
||||
};
|
||||
w.gtag("js", new Date());
|
||||
// The landing referrer may be a same-origin secret URL (full-page hop
|
||||
// off /login?cli_state=…); pin a sanitized copy instead.
|
||||
w.gtag("config", measurementId, {
|
||||
page_location: sanitizeAnalyticsUrl(w.location.href),
|
||||
page_referrer: sanitizeAnalyticsUrl(document.referrer),
|
||||
});
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.async = true;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
const maybeLoad = () => {
|
||||
if (stopped || loaded) return;
|
||||
if (isSensitiveAnalyticsUrl(w.location.href)) {
|
||||
w[disableKey] = true;
|
||||
return;
|
||||
}
|
||||
load();
|
||||
};
|
||||
|
||||
const originalPush = w.history.pushState;
|
||||
const originalReplace = w.history.replaceState;
|
||||
const wrap =
|
||||
(original: History["pushState"]): History["pushState"] =>
|
||||
function (this: History, data, unused, url) {
|
||||
const target = url == null ? w.location.href : new URL(String(url), w.location.href).href;
|
||||
// Stop before any tag history listener can observe the new URL.
|
||||
if (loaded && (isSensitiveAnalyticsUrl(w.location.href) || isSensitiveAnalyticsUrl(target))) {
|
||||
stop();
|
||||
}
|
||||
const result = original.call(this, data, unused, url);
|
||||
// Initial loading must happen after a successful navigation, with the
|
||||
// real location already clean. No tag has observed earlier URLs yet.
|
||||
maybeLoad();
|
||||
return result;
|
||||
};
|
||||
w.history.pushState = wrap(originalPush);
|
||||
w.history.replaceState = wrap(originalReplace);
|
||||
|
||||
// Capture phase, registered before gtag.js: runs ahead of gtag's listener.
|
||||
const onPopState = () => {
|
||||
if (loaded && isSensitiveAnalyticsUrl(w.location.href)) stop();
|
||||
maybeLoad();
|
||||
};
|
||||
w.addEventListener("popstate", onPopState, true);
|
||||
|
||||
// GA's enhanced measurement also reports the full target URL of outbound
|
||||
// link clicks and file downloads (`link_url`), and the form action of form
|
||||
// submissions (`form_destination`) — without the page URL changing. Signed
|
||||
// attachment downloads (CloudFront / S3 presign / proxy capability) click a
|
||||
// credential-bearing link, so stop before gtag's own listeners see such an
|
||||
// interaction. Window capture runs ahead of any document-level listener.
|
||||
const onLinkInteraction = (event: Event) => {
|
||||
if (!loaded || stopped) return;
|
||||
const href = interactionLinkHref(event);
|
||||
if (href && isSensitiveInteractionUrl(href)) stop();
|
||||
};
|
||||
w.addEventListener("click", onLinkInteraction, true);
|
||||
w.addEventListener("auxclick", onLinkInteraction, true);
|
||||
w.addEventListener(
|
||||
"submit",
|
||||
(event) => {
|
||||
if (!loaded || stopped) return;
|
||||
const form = event.target;
|
||||
if (form instanceof HTMLFormElement && isSensitiveInteractionUrl(form.action)) stop();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
maybeLoad();
|
||||
}
|
||||
|
||||
// The href of the link an interaction activates, resolved to an absolute URL.
|
||||
// composedPath() also reaches anchors inside open shadow roots and SVG links.
|
||||
function interactionLinkHref(event: Event): string | null {
|
||||
for (const node of event.composedPath()) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
if (node.localName !== "a" && node.localName !== "area") continue;
|
||||
const raw = node.getAttribute("href") ?? node.getAttribute("xlink:href");
|
||||
if (raw == null) continue;
|
||||
try {
|
||||
return new URL(raw, document.baseURI).href;
|
||||
} catch {
|
||||
// Unparseable: treat as opaque and let the sensitivity check decide.
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Object URLs (CSV / diagram exports) are an opaque same-origin UUID and carry
|
||||
// no credential; everything else uses the page-URL rule (non-allowlisted query
|
||||
// params, capability paths, or a non-http(s) scheme).
|
||||
function isSensitiveInteractionUrl(url: string): boolean {
|
||||
if (url.startsWith("blob:")) return false;
|
||||
return isSensitiveAnalyticsUrl(url);
|
||||
}
|
||||
@@ -46,6 +46,10 @@ spec:
|
||||
- name: NEXT_PUBLIC_WS_URL
|
||||
value: {{ .Values.frontend.config.publicWsUrl | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.frontend.config.googleAnalyticsId }}
|
||||
- name: GOOGLE_ANALYTICS_ID
|
||||
value: {{ .Values.frontend.config.googleAnalyticsId | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.frontend.resources | nindent 12 }}
|
||||
{{- with .Values.frontend.affinity }}
|
||||
|
||||
@@ -197,6 +197,8 @@ frontend:
|
||||
# default same-origin frontend proxy.
|
||||
publicApiUrl: ""
|
||||
publicWsUrl: ""
|
||||
# Optional GA4 Measurement ID (G-XXXXXXXXXX). Empty keeps gtag.js off.
|
||||
googleAnalyticsId: ""
|
||||
# Compatibility shim for legacy multica-web images built before runtime
|
||||
# URL rewrites. New images do not need this unprefixed Service.
|
||||
compatibility:
|
||||
|
||||
@@ -231,6 +231,7 @@ services:
|
||||
DOCS_URL: ${DOCS_URL:-}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
GOOGLE_ANALYTICS_ID: ${GOOGLE_ANALYTICS_ID:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
Reference in New Issue
Block a user