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-");
+ });
+});