From b860eeaf29dd280d565d08a575cf57498f3a2474 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 05:12:21 +0000 Subject: [PATCH] =?UTF-8?q?R=C3=A9f=C3=A9rencement=20:=20Google=20Analytic?= =?UTF-8?q?s=20et=20Search=20Console=20r=C3=A9glables=20en=20backoffice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux réglages dans Backend › Paramètres : - `google_analytics_id` pose les balises gtag.js. Vide = rien n'est chargé. `src/lib/analytics.ts` (pur) n'accepte qu'un identifiant GA4 « G-… » : la saisie ne peut donc pas s'échapper du script en ligne. Les deux ``. + * Les identifiants Universal Analytics (`UA-…`), arrêtés depuis 2023, sont + * refusés : ils ne collectent plus rien. + */ +export function normalizeMeasurementId(raw: string | null | undefined): string { + const value = (raw ?? "").trim().toUpperCase(); + return MEASUREMENT_ID.test(value) ? value : ""; +} + +/** URL du chargeur gtag.js pour un identifiant **déjà normalisé**. */ +export function gtagSrc(measurementId: string): string { + const id = normalizeMeasurementId(measurementId); + return id ? `https://www.googletagmanager.com/gtag/js?id=${id}` : ""; +} + +/** + * Script d'amorçage de gtag.js. Il porte le nonce de la CSP comme le reste des + * scripts du site ; les scripts que gtag.js charge ensuite passent grâce à + * `'strict-dynamic'`. + */ +export function gtagConfigScript(measurementId: string): string { + const id = normalizeMeasurementId(measurementId); + if (!id) return ""; + return [ + "window.dataLayer=window.dataLayer||[];", + "function gtag(){dataLayer.push(arguments);}", + "gtag('js',new Date());", + `gtag('config','${id}');`, + ].join(""); +} + +/** + * Seules les pages publiques sont mesurées. + * + * L'espace adhérent et le backoffice portent des identifiants de membres et de + * promotions dans leurs URLs : les envoyer à un tiers n'apporterait rien et + * ferait sortir des données personnelles du site. + */ +export function isTrackedPath(pathname: string): boolean { + const path = pathname.split("?")[0] ?? "/"; + return !PRIVATE_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); +} + +/** Jeton de validation Search Console (balise `google-site-verification`). */ +export function normalizeSiteVerification(raw: string | null | undefined): string { + const value = (raw ?? "").trim(); + // Google livre un jeton alphanumérique d'une quarantaine de caractères ; on + // refuse le reste — dont la balise complète, collée telle quelle — + // plutôt que de recopier dans le une chaîne venue de n'importe où. + return /^[A-Za-z0-9_-]{20,128}$/.test(value) ? value : ""; +} diff --git a/src/lib/site-settings.ts b/src/lib/site-settings.ts index 2c9ba60..5b99f1d 100644 --- a/src/lib/site-settings.ts +++ b/src/lib/site-settings.ts @@ -44,6 +44,10 @@ export const SITE_SETTING_DEFAULTS = { association_publication_director: "", association_privacy_contact: "", legal_updated: "", + // Référencement : identifiant de mesure GA4 (« G-… ») et jeton de + // validation Search Console. Vides = aucune balise posée sur le site. + google_analytics_id: "", + google_site_verification: "", }; export type SiteSettings = typeof SITE_SETTING_DEFAULTS; diff --git a/src/middleware.ts b/src/middleware.ts index 2bb4fee..1f198c1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -32,7 +32,11 @@ function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean): "font-src 'self' https://fonts.gstatic.com", // data: pour les images stockées en data-URI, https: pour les vignettes distantes. "img-src 'self' data: https:", - "connect-src 'self'", + // Mesure d'audience GA4 : gtag.js renvoie ses relevés par `fetch`. Les + // hôtes sont listés même sans identifiant configuré — la CSP est posée + // sur le runtime Edge, qui ne peut pas interroger la base pour savoir si + // la mesure est active. + "connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com", // Carte « Nous situer » de la fiche adhérent. "frame-src https://www.google.com https://maps.google.com", // Uniquement quand la page est servie en HTTPS : sur une page HTTP (test @@ -55,6 +59,9 @@ export default auth((request: NextRequest) => { // ses balises ", + 'G-ABC"12345', + "G-ABC 12345", + "", + ]) { + assert.equal(normalizeMeasurementId(payload), "", `accepté à tort : ${payload}`); + } + }); +}); + +describe("Balises gtag.js", () => { + it("ne rend rien sans identifiant valide", () => { + assert.equal(gtagSrc(""), ""); + assert.equal(gtagSrc("UA-12345-1"), ""); + assert.equal(gtagConfigScript(""), ""); + assert.equal(gtagConfigScript("pas-un-identifiant"), ""); + }); + + it("charge gtag.js depuis Google avec l'identifiant configuré", () => { + assert.equal( + gtagSrc("G-ABC1234567"), + "https://www.googletagmanager.com/gtag/js?id=G-ABC1234567", + ); + }); + + it("produit un script d'amorçage qui ne peut pas être refermé", () => { + const script = gtagConfigScript("G-ABC1234567"); + assert.ok(script.includes("gtag('config','G-ABC1234567');")); + assert.ok(!script.includes(" { + it("mesure les pages publiques", () => { + for (const path of ["/", "/annuaire", "/promotions", "/adherents/12-au-bon-pain", "/association"]) { + assert.equal(isTrackedPath(path), true, `devrait être mesurée : ${path}`); + } + }); + + it("ne mesure ni le backoffice, ni l'espace adhérent, ni les URLs privées", () => { + for (const path of [ + "/backend", + "/backend/espace/promotions", + "/login", + "/inscription/adherent", + "/api/social/facebook/callback", + ]) { + assert.equal(isTrackedPath(path), false, `ne devrait pas être mesurée : ${path}`); + } + }); + + it("ne confond pas un préfixe avec un début de mot", () => { + assert.equal(isTrackedPath("/backendeur"), true); + assert.equal(isTrackedPath("/apiculteurs"), true); + }); +}); + +describe("Jeton Search Console", () => { + it("accepte le jeton livré par Google", () => { + assert.equal( + normalizeSiteVerification(" aBc123_def-456ghijkl "), + "aBc123_def-456ghijkl", + ); + }); + + it("refuse une balise complète ou tout autre collage", () => { + for (const payload of [ + '', + "", + "trop-court", + "abc123 def456ghij", + "abc\">", + ]) { + assert.equal(normalizeSiteVerification(payload), "", `accepté à tort : ${payload}`); + } + }); +});