Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
18 changes: 18 additions & 0 deletions src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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.',
Expand Down Expand Up @@ -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 (
<html lang={locale}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* 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 ? <GoogleAnalytics measurementId={gaId} {...(nonce ? { nonce } : {})} /> : null}
{gaId ? <GoogleAnalyticsPageviews measurementId={gaId} /> : null}
<Providers>
<NextIntlClientProvider messages={messages}>
<AppLayout>{children}</AppLayout>
Expand Down
130 changes: 130 additions & 0 deletions src/app/api/analytics/overseer/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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 });
}
}
5 changes: 5 additions & 0 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions src/components/analytics/google-analytics-pageviews.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
49 changes: 49 additions & 0 deletions src/components/analytics/google-analytics.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
{...scriptProps}
/>
<script
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${measurementId}', {
cookie_domain: 'auto',
sample_rate: 5,
send_page_view: false
});`,
}}
{...scriptProps}
/>
</>
);
}
28 changes: 28 additions & 0 deletions src/components/analytics/overseer-page-tracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import { useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { usePathname } from '@/i18n/routing';
import type { RootState } from '@/lib/store';
import { recordRouteTag } from '@/lib/analytics/overseer';
import { routeTagFromPathname } from '@/lib/analytics/overseer-payload';

/**
* Legacy parity: each page dispatched `setRouteTag`, which saga'd into
* `overseer.collect` measurement `route`. One listener covers App Router navigations.
*/
export function OverseerPageTracker() {
const pathname = usePathname();
const isLogin = useSelector((state: RootState) => state.auth.isAuthenticated);
const last = useRef<string | null>(null);

useEffect(() => {
const key = `${pathname}|${isLogin ? '1' : '0'}`;
if (last.current === key) return;
last.current = key;
const mapped = routeTagFromPathname(pathname);
recordRouteTag(mapped.tag, mapped.params, isLogin);
}, [pathname, isLogin]);

return null;
}
2 changes: 2 additions & 0 deletions src/components/layout/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Header } from './header';
import { SidePanel } from './side-panel';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DegradationBanner } from './degradation-banner';
import { OverseerPageTracker } from '@/components/analytics/overseer-page-tracker';
import { useState } from 'react';
import { Toaster } from 'sonner';

Expand All @@ -13,6 +14,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<TooltipProvider>
<div className="min-h-screen">
<OverseerPageTracker />
<Toaster richColors closeButton />
<Header onOpenSidePanel={() => setSidePanelOpen(true)} />
<DegradationBanner />
Expand Down
2 changes: 2 additions & 0 deletions src/components/wallet/cancel-power-down-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useSelector } from 'react-redux';
import type { RootState } from '@/lib/store';
import { useActiveSigningKey } from '@/hooks/use-auth';
import { SteemSigner, apiClient } from '@/lib/steem/client';
import { userActionRecord } from '@/lib/analytics/overseer';

export interface CancelPowerDownHandlerProps {
onSuccess: () => void;
Expand Down Expand Up @@ -33,6 +34,7 @@ export function CancelPowerDownHandler({ onSuccess, onCancel }: CancelPowerDownH
setError(response.error || t('cancelError'));
return;
}
userActionRecord('cancel_withdraw_vesting', { username });
onSuccess();
} catch (err) {
console.error('Cancel power down error:', err);
Expand Down
2 changes: 2 additions & 0 deletions src/components/wallet/change-password-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { userActionRecord } from '@/lib/analytics/overseer';

export interface ChangePasswordSectionProps {
username: string;
Expand Down Expand Up @@ -116,6 +117,7 @@ export function ChangePasswordSection({

clearRememberedPostingKey();
setChangeSuccess(true);
userActionRecord('change_password', { username });
await logout();
} catch (err) {
const message = err instanceof Error ? err.message : t('broadcastFailed');
Expand Down
Loading
Loading