Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes
File renamed without changes
File renamed without changes
11 changes: 10 additions & 1 deletion src/components/analytics/google-analytics-pageviews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 9 additions & 7 deletions src/components/analytics/google-analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
});`,
}}
Expand Down
24 changes: 24 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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).*)',
],
Expand Down
18 changes: 14 additions & 4 deletions tests/unit/google-analytics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<GoogleAnalytics measurementId="G-TESTID2" nonce="abc123" />);
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);}');
});
Expand All @@ -58,15 +60,23 @@ describe('GoogleAnalyticsPageviews (SPA virtual pageviews)', () => {
it('reports the initial pageview on mount (loader ran at parse time)', () => {
window.gtag = vi.fn();
render(<GoogleAnalyticsPageviews measurementId="G-TESTID" />);
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', () => {
window.gtag = vi.fn();
const { rerender } = render(<GoogleAnalyticsPageviews measurementId="G-TESTID" />);
mockPathname.mockReturnValue('/proposals');
rerender(<GoogleAnalyticsPageviews measurementId="G-TESTID" />);
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)', () => {
Expand Down
50 changes: 47 additions & 3 deletions tests/unit/proxy-csp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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-");
});
});
Loading