diff --git a/.env.example b/.env.example index d7873d01..e15d8143 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,16 @@ STEEM_RPC_URL=https://api.steemit.com # Optional: Mixpanel analytics (client-side token) # NEXT_PUBLIC_MIXPANEL_TOKEN=your-mixpanel-token +# Optional: Google Analytics measurement ID (gtag). Read at runtime by the +# server layout (no rebuild required). Legacy: SDC_GOOGLE_ANALYTICS_ID. +# GOOGLE_ANALYTICS_ID=G-XXXXXXXX +# SDC_GOOGLE_ANALYTICS_ID=G-XXXXXXXX + +# Optional: overseer whale thresholds (legacy config steem_whale / sbd_whale). +# Used when relaying user_action events. Defaults: 10000 STEEM / 500 SBD. +# STEEM_WHALE=10000 +# SBD_WHALE=500 + # Optional: Rate limiting (Redis-backed when REDIS_URL is set; per-process # in-memory fallback otherwise — not shared across instances). # RATE_LIMIT_ALLOW_MEMORY_FALLBACK=false # reject (503) instead of falling back diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1f25ddd4..146e0401 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -37,6 +37,11 @@ services: # Analytics - MIXPANEL_TOKEN=${MIXPANEL_TOKEN:-} - ANALYTICS_ENABLED=${ANALYTICS_ENABLED:-false} + # Runtime gtag ID (legacy SDC_GOOGLE_ANALYTICS_ID). Empty = script not injected. + - GOOGLE_ANALYTICS_ID=${GOOGLE_ANALYTICS_ID:-} + - SDC_GOOGLE_ANALYTICS_ID=${SDC_GOOGLE_ANALYTICS_ID:-} + - STEEM_WHALE=${STEEM_WHALE:-10000} + - SBD_WHALE=${SBD_WHALE:-500} # Rate Limiting - RATE_LIMIT_ENABLED=${RATE_LIMIT_ENABLED:-true} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index e06664a1..a3d09adb 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -2,10 +2,14 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; +import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import { routing } from '@/i18n/routing'; import { Providers } from '../providers'; import { AppLayout } from '@/components/layout/app-layout'; +import { GoogleAnalytics } from '@/components/analytics/google-analytics'; +import { GoogleAnalyticsPageviews } from '@/components/analytics/google-analytics-pageviews'; +import { getGaMeasurementId } from '@/lib/analytics/ga-id'; import '../globals.css'; const geistSans = Geist({ @@ -18,6 +22,12 @@ const geistMono = Geist_Mono({ subsets: ['latin'], }); +// All routes render per-request (condenser #4012 parity): runtime env like +// GOOGLE_ANALYTICS_ID must never be baked into prerendered HTML. Pages are +// already dynamic because of the CSP-nonce headers() call below; this export +// makes the invariant explicit and independent of that mechanism. +export const dynamic = 'force-dynamic'; + export const metadata: Metadata = { title: 'Steemit Wallet', description: 'Steemit Wallet is an online wallet for managing Steem accounts.', @@ -55,12 +65,20 @@ export default async function LocaleLayout({ // Providing all messages to the client // side is the easiest way to get started const messages = await getMessages(); + const gaId = getGaMeasurementId(); + const nonce = (await headers()).get('x-nonce') ?? undefined; return ( + {/* GA scripts must stay in a fragment free of 'use client' elements — + a client sibling inside the SAME fragment makes React defer the + scripts to hydration instead of emitting them in the SSR HTML. + Hence two separate conditionals, not one shared fragment. */} + {gaId ? : null} + {gaId ? : null} {children} diff --git a/src/app/api/analytics/overseer/route.ts b/src/app/api/analytics/overseer/route.ts new file mode 100644 index 00000000..b1a58ade --- /dev/null +++ b/src/app/api/analytics/overseer/route.ts @@ -0,0 +1,130 @@ +// POST /api/analytics/overseer +// Relay frontend overseer.collect events (route tags + user actions). +// Shape/allowlist only — the chain-side overseer service is the sink. +import { NextRequest, NextResponse } from 'next/server'; +import { verifyCSRF, rateLimit } from '@/lib/middleware'; +import { SteemService } from '@/lib/steem/server'; +import { + buildRoutePayload, + buildUserActionPayload, + isOverseerRouteTag, + isOverseerUserAction, + isSteemAccountName, + isTrackingId, + whaleThresholdsFromEnv, + type UserActionParams, +} from '@/lib/analytics/overseer-payload'; + +const MAX_STRING = 64; +const MAX_AMOUNT = 1e15; + +function asOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_STRING) return undefined; + return trimmed; +} + +function asOptionalAccount(value: unknown): string | undefined { + const s = asOptionalString(value); + if (!s) return undefined; + const name = s.replace(/^@/, '').toLowerCase(); + return isSteemAccountName(name) ? name : undefined; +} + +function asOptionalAmount(value: unknown): string | number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= MAX_AMOUNT) { + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_STRING) return undefined; + const n = parseFloat(trimmed.split(' ')[0] ?? ''); + if (!Number.isFinite(n) || n < 0 || n > MAX_AMOUNT) return undefined; + return trimmed; + } + return undefined; +} + +function sanitizeActionParams(raw: unknown): UserActionParams { + if (!raw || typeof raw !== 'object') return {}; + const o = raw as Record; + const params: UserActionParams = {}; + const username = asOptionalAccount(o.username); + const from = asOptionalAccount(o.from); + const to = asOptionalAccount(o.to); + const witness = asOptionalAccount(o.witness); + // Proxy may be empty string (clear proxy). Allow '' or a valid account. + let proxy: string | undefined; + if (typeof o.proxy === 'string') { + const p = o.proxy.trim().replace(/^@/, '').toLowerCase(); + if (p === '') proxy = ''; + else if (isSteemAccountName(p)) proxy = p; + } + const transferCoin = asOptionalString(o.transferCoin); + const amount = asOptionalAmount(o.amount); + if (username !== undefined) params.username = username; + if (from !== undefined) params.from = from; + if (to !== undefined) params.to = to; + if (witness !== undefined) params.witness = witness; + if (proxy !== undefined) params.proxy = proxy; + if (transferCoin !== undefined) params.transferCoin = transferCoin; + if (amount !== undefined) params.amount = amount; + return params; +} + +export async function POST(request: NextRequest) { + try { + const csrfError = await verifyCSRF(request); + if (csrfError) return csrfError; + + const rateLimitError = await rateLimit(request, 'analytics', { + maxRequests: 100, + windowSeconds: 60, + }); + if (rateLimitError) return rateLimitError; + + const body = (await request.json()) as Record; + const kind = body.kind; + + if (kind === 'route') { + const tag = typeof body.tag === 'string' ? body.tag : ''; + const trackingId = typeof body.trackingId === 'string' ? body.trackingId : ''; + if (!isOverseerRouteTag(tag) || !isTrackingId(trackingId)) { + return NextResponse.json({ error: 'Invalid route event' }, { status: 400 }); + } + const accountname = asOptionalAccount( + body.params && typeof body.params === 'object' + ? (body.params as Record).accountname + : undefined + ); + const payload = buildRoutePayload( + trackingId, + tag, + accountname ? { accountname } : undefined, + body.isLogin === true + ); + await SteemService.collectOverseer(payload); + return NextResponse.json({ success: true }); + } + + if (kind === 'action') { + const action = typeof body.action === 'string' ? body.action : ''; + if (!isOverseerUserAction(action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + const payload = buildUserActionPayload( + action, + sanitizeActionParams(body.params), + whaleThresholdsFromEnv() + ); + await SteemService.collectOverseer(payload); + return NextResponse.json({ success: true }); + } + + return NextResponse.json({ error: 'Invalid event kind' }, { status: 400 }); + } catch (error) { + console.error('Overseer analytics error:', error); + return NextResponse.json({ success: true }); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 35d3c805..78632c85 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { SteemService } from '@/lib/steem/server'; import { verifyCSRF, rateLimit } from '@/lib/middleware'; import { getRedis, redisKey } from '@/lib/cache/redis'; +import { buildUserLoginPayload } from '@/lib/analytics/overseer-payload'; export async function POST(request: NextRequest) { try { @@ -102,6 +103,10 @@ export async function POST(request: NextRequest) { ); } + // Legacy `/login_account` checkpoint: overseer measurement `user_login`. + // Do not await — a slow/missing overseer must not delay the session response. + void SteemService.collectOverseer(buildUserLoginPayload(account.name)); + // Return success with account info return NextResponse.json({ success: true, diff --git a/src/components/analytics/google-analytics-pageviews.tsx b/src/components/analytics/google-analytics-pageviews.tsx new file mode 100644 index 00000000..c5925b0e --- /dev/null +++ b/src/components/analytics/google-analytics-pageviews.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useEffect } from 'react'; +// next/navigation's usePathname, NOT @/i18n/routing's: this component renders +// in the layout OUTSIDE NextIntlClientProvider, where next-intl's usePathname +// throws during SSR (React's error recovery then swallows sibling server +// content). With localePrefix 'never' both hooks return the same path. +import { usePathname } from 'next/navigation'; + +declare global { + interface Window { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + } +} + +/** + * SPA virtual pageviews for gtag. The loader + init scripts render in the SSR + * HTML (see google-analytics.tsx) and execute at parse time, so window.gtag + * 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). + */ +export function GoogleAnalyticsPageviews({ measurementId }: { measurementId: string }) { + const pathname = usePathname(); + + useEffect(() => { + if (typeof window.gtag !== 'function') return; + window.gtag('config', measurementId, { page_path: pathname }); + }, [pathname, measurementId]); + + return null; +} diff --git a/src/components/analytics/google-analytics.tsx b/src/components/analytics/google-analytics.tsx new file mode 100644 index 00000000..12df7f32 --- /dev/null +++ b/src/components/analytics/google-analytics.tsx @@ -0,0 +1,49 @@ +/** + * 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). + * + * measurementId must arrive validated (getGaMeasurementId — strict GA id + * regex) since it is interpolated into an inline script. + * + * Both scripts carry the CSP nonce: with 'strict-dynamic' the host allowlist + * only applies to legacy browsers, so an un-nonced script would be blocked. + */ +export function GoogleAnalytics({ + measurementId, + nonce, +}: { + measurementId: string; + nonce?: string; +}) { + const scriptProps = nonce ? { nonce } : {}; + + return ( + <> +