From a26760982eede3d57768a7383e489c13f5693ce6 Mon Sep 17 00:00:00 2001 From: ety001 Date: Fri, 4 Sep 2026 02:42:05 +0800 Subject: [PATCH] fix(analytics,static): GA pageviews never sent; public assets 404 via intl rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPA pageviews used gtag('config', page_path) after send_page_view:false, which does not reliably emit a page_view — switch to the documented gtag('event', 'page_view') pattern; drop the UA-era sample_rate (absent from the GA4 config reference). - favicons lived in app/favicons (never served); move to public/favicons. - Static requests reached next-intl and were rewritten into /[locale]/, 404ing every nested public asset (favicons, images/**): proxy now passes file-like paths through before intl; /@-rooted account paths unaffected. --- .../favicons/android-chrome-192x192.png | Bin .../favicons/apple-touch-icon.png | Bin .../app => public}/favicons/favicon-16x16.png | Bin .../app => public}/favicons/favicon-32x32.png | Bin .../app => public}/favicons/favicon-96x96.png | Bin .../analytics/google-analytics-pageviews.tsx | 11 +++- src/components/analytics/google-analytics.tsx | 16 +++--- src/proxy.ts | 24 +++++++++ tests/unit/google-analytics.test.tsx | 18 +++++-- tests/unit/proxy-csp.test.ts | 50 ++++++++++++++++-- 10 files changed, 104 insertions(+), 15 deletions(-) rename {src/app => public}/favicons/android-chrome-192x192.png (100%) rename {src/app => public}/favicons/apple-touch-icon.png (100%) rename {src/app => public}/favicons/favicon-16x16.png (100%) rename {src/app => public}/favicons/favicon-32x32.png (100%) rename {src/app => public}/favicons/favicon-96x96.png (100%) diff --git a/src/app/favicons/android-chrome-192x192.png b/public/favicons/android-chrome-192x192.png similarity index 100% rename from src/app/favicons/android-chrome-192x192.png rename to public/favicons/android-chrome-192x192.png diff --git a/src/app/favicons/apple-touch-icon.png b/public/favicons/apple-touch-icon.png similarity index 100% rename from src/app/favicons/apple-touch-icon.png rename to public/favicons/apple-touch-icon.png diff --git a/src/app/favicons/favicon-16x16.png b/public/favicons/favicon-16x16.png similarity index 100% rename from src/app/favicons/favicon-16x16.png rename to public/favicons/favicon-16x16.png diff --git a/src/app/favicons/favicon-32x32.png b/public/favicons/favicon-32x32.png similarity index 100% rename from src/app/favicons/favicon-32x32.png rename to public/favicons/favicon-32x32.png diff --git a/src/app/favicons/favicon-96x96.png b/public/favicons/favicon-96x96.png similarity index 100% rename from src/app/favicons/favicon-96x96.png rename to public/favicons/favicon-96x96.png diff --git a/src/components/analytics/google-analytics-pageviews.tsx b/src/components/analytics/google-analytics-pageviews.tsx index c5925b0ec..e6b5135cc 100644 --- a/src/components/analytics/google-analytics-pageviews.tsx +++ b/src/components/analytics/google-analytics-pageviews.tsx @@ -20,13 +20,22 @@ declare global { * exists before hydration and the first effect run already reports the * initial pageview — the init config sets send_page_view:false so this effect * is the single source of pageviews (no double-count on first load). + * + * Must use gtag('event', 'page_view', ...), NOT another gtag('config') call: + * once send_page_view:false is set, re-issuing config with page_path does not + * reliably emit a page_view (GA4 "Measure pageviews" docs). page_location / + * page_referrer are filled automatically by gtag. */ export function GoogleAnalyticsPageviews({ measurementId }: { measurementId: string }) { const pathname = usePathname(); useEffect(() => { if (typeof window.gtag !== 'function') return; - window.gtag('config', measurementId, { page_path: pathname }); + window.gtag('event', 'page_view', { + page_path: pathname, + page_title: document.title, + send_to: measurementId, + }); }, [pathname, measurementId]); return null; diff --git a/src/components/analytics/google-analytics.tsx b/src/components/analytics/google-analytics.tsx index 12df7f32d..e46edf5ef 100644 --- a/src/components/analytics/google-analytics.tsx +++ b/src/components/analytics/google-analytics.tsx @@ -2,12 +2,15 @@ * gtag loader rendered straight into the SSR HTML so the browser loads it at * parse time — no client-side injection, no hydration dependency (wallet-legacy * `server-html.jsx` parity; also what condenser settled on in its #4010 fix). - * Init config matches legacy `JsPlugins.js`: cookie_domain auto, sample_rate 5, - * send_page_view:false (SPA pageviews are reported by GoogleAnalyticsPageviews - * — render it as a SEPARATE sibling conditional in the layout, never inside - * the same fragment: any 'use client' element sharing a fragment with these - * scripts makes React defer them to hydration instead of emitting them in the - * SSR HTML). + * Init config: cookie_domain auto + send_page_view:false (SPA pageviews are + * reported by GoogleAnalyticsPageviews — render it as a SEPARATE sibling + * conditional in the layout, never inside the same fragment: any 'use client' + * element sharing a fragment with these scripts makes React defer them to + * hydration instead of emitting them in the SSR HTML). + * + * Legacy's sample_rate:5 is intentionally NOT carried over: it is a Universal + * Analytics field, absent from the GA4 config reference — dead code for G- + * measurement ids. * * measurementId must arrive validated (getGaMeasurementId — strict GA id * regex) since it is interpolated into an inline script. @@ -38,7 +41,6 @@ function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', '${measurementId}', { cookie_domain: 'auto', - sample_rate: 5, send_page_view: false });`, }} diff --git a/src/proxy.ts b/src/proxy.ts index f95abd0cd..f8ceb1658 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -128,6 +128,22 @@ function accountPathWithoutAtPrefix(pathname: string): string | null { return `/${account}${suffix}`; } +// Static assets are served from public/, but the middleware matcher cannot +// use "path contains a dot" to skip them (Steem sub-accounts use dots, e.g. +// /@user.subaccount). Requests that reach next-intl are internally rewritten +// into the /[locale]/ tree, which 404s every nested public asset +// (/favicons/*, /images/**). Pass file-like paths through untouched instead. +// The check is anchored to the LAST segment's extension, so account paths +// (/@user.transfers-page style names) are unaffected — except /@…-rooted +// paths, which are always account URLs and never public files. +const STATIC_ASSET_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|avif|ico|txt|xml|css|js|mjs|map|woff2?|ttf|otf|eot)$/i; + +function isStaticAssetRequest(pathname: string): boolean { + if (pathname.startsWith('/@')) return false; + const lastSegment = pathname.split('/').pop() ?? ''; + return STATIC_ASSET_EXT_RE.test(lastSegment); +} + export default async function proxy(request: NextRequest) { // Intercept health check endpoint used by ELB and OpenResty. // Must return before i18n middleware to avoid locale redirect issues. @@ -137,6 +153,12 @@ export default async function proxy(request: NextRequest) { return NextResponse.json({ status: 'ok' }); } + // Static assets: no CSP nonce, no CSRF cookie, no locale rewriting — + // Next.js serves them straight from public/. + if (isStaticAssetRequest(request.nextUrl.pathname)) { + return NextResponse.next(); + } + const normalized = accountPathWithoutAtPrefix(request.nextUrl.pathname); // Attach the per-request nonce to the request headers BEFORE the intl middleware builds its @@ -179,6 +201,8 @@ export default async function proxy(request: NextRequest) { export const config = { // Match all pathnames except api and Next internals. // Do NOT use "path contains a dot" (.*\..*) — Steem sub-accounts use dots (e.g. user.subaccount). + // Static files are NOT excluded here either; isStaticAssetRequest() above + // passes them through before next-intl can rewrite them into /[locale]/. matcher: [ '/((?!api|trpc|_next|_vercel|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)', ], diff --git a/tests/unit/google-analytics.test.tsx b/tests/unit/google-analytics.test.tsx index eccfde34c..3fdb0a518 100644 --- a/tests/unit/google-analytics.test.tsx +++ b/tests/unit/google-analytics.test.tsx @@ -28,15 +28,17 @@ describe('GoogleAnalytics (SSR scripts)', () => { expect(loader!.getAttribute('nonce')).toBe('abc123'); }); - it('renders the inline init with the legacy config and the nonce', () => { + it('renders the inline init with the GA4 config and the nonce', () => { render(); const init = document.querySelector('script:not([src])'); expect(init).toBeTruthy(); expect(init!.getAttribute('nonce')).toBe('abc123'); expect(init!.textContent).toContain("gtag('config', 'G-TESTID2'"); expect(init!.textContent).toContain('cookie_domain: \'auto\''); - expect(init!.textContent).toContain('sample_rate: 5'); expect(init!.textContent).toContain('send_page_view: false'); + // sample_rate is a Universal-Analytics-only field (absent from the GA4 + // config reference) — it must not be carried over. + expect(init!.textContent).not.toContain('sample_rate'); // Queue shim must be self-contained (no external dependency at parse time). expect(init!.textContent).toContain('function gtag(){dataLayer.push(arguments);}'); }); @@ -58,7 +60,11 @@ describe('GoogleAnalyticsPageviews (SPA virtual pageviews)', () => { it('reports the initial pageview on mount (loader ran at parse time)', () => { window.gtag = vi.fn(); render(); - expect(window.gtag).toHaveBeenCalledWith('config', 'G-TESTID', { page_path: '/market' }); + expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', { + page_path: '/market', + page_title: expect.any(String), + send_to: 'G-TESTID', + }); }); it('reports a pageview whenever the pathname changes', () => { @@ -66,7 +72,11 @@ describe('GoogleAnalyticsPageviews (SPA virtual pageviews)', () => { const { rerender } = render(); mockPathname.mockReturnValue('/proposals'); rerender(); - expect(window.gtag).toHaveBeenLastCalledWith('config', 'G-TESTID', { page_path: '/proposals' }); + expect(window.gtag).toHaveBeenLastCalledWith('event', 'page_view', { + page_path: '/proposals', + page_title: expect.any(String), + send_to: 'G-TESTID', + }); }); it('does nothing when gtag is unavailable (ad-blockers)', () => { diff --git a/tests/unit/proxy-csp.test.ts b/tests/unit/proxy-csp.test.ts index b58ebcd50..074d5cfab 100644 --- a/tests/unit/proxy-csp.test.ts +++ b/tests/unit/proxy-csp.test.ts @@ -2,10 +2,15 @@ import { describe, it, expect, vi } from 'vitest'; import { NextRequest, NextResponse } from 'next/server'; // next-intl's middleware module cannot be resolved in the vitest environment; -// the CSP/nonce behavior under test does not depend on intl routing. +// the CSP/nonce behavior under test does not depend on intl routing. The mock +// handle must be hoisted so the vi.mock factory can reference it. +const { intlMock } = vi.hoisted(() => ({ + intlMock: vi.fn((request: NextRequest) => + NextResponse.next({ request: { headers: request.headers } }) + ), +})); vi.mock('next-intl/middleware', () => ({ - default: () => (request: NextRequest) => - NextResponse.next({ request: { headers: request.headers } }), + default: () => intlMock, })); vi.mock('@/i18n/routing', () => ({ routing: { locales: ['en'], defaultLocale: 'en' }, @@ -62,3 +67,42 @@ describe('proxy CSP nonce', () => { expect(res.headers.get('content-security-policy')).toBeNull(); }); }); + +describe('proxy static asset passthrough', () => { + it('passes public asset requests straight through (no intl rewrite)', async () => { + intlMock.mockClear(); + for (const path of [ + '/favicons/favicon-16x16.png', + '/images/about/mission.jpg', + '/file.svg', + '/favicons/apple-touch-icon.png', + ]) { + const res = await proxy(req(path)); + expect(res.status).toBe(200); + expect(intlMock).not.toHaveBeenCalled(); + intlMock.mockClear(); + } + }); + + it('still routes account paths that merely contain dots', async () => { + intlMock.mockClear(); + await proxy(req('/@user.subaccount')); + expect(intlMock).toHaveBeenCalledTimes(1); + intlMock.mockClear(); + await proxy(req('/user.subaccount/transfers')); + expect(intlMock).toHaveBeenCalledTimes(1); + }); + + it('treats /@-rooted paths as accounts even with an asset extension', async () => { + intlMock.mockClear(); + await proxy(req('/@user.png')); + expect(intlMock).toHaveBeenCalledTimes(1); + }); + + it('applies CSP + csrf cookie to page requests as before', async () => { + intlMock.mockClear(); + const res = await proxy(req('/market')); + expect(intlMock).toHaveBeenCalledTimes(1); + expect(res.headers.get('content-security-policy')).toContain("'nonce-"); + }); +});