diff --git a/next.config.js b/next.config.js index 5534f8e..eee0158 100644 --- a/next.config.js +++ b/next.config.js @@ -32,6 +32,14 @@ const config = { defaultLocale: 'en', }, transpilePackages: ['geist'], + // Analytics runs on our own origin. Umami derives its collect endpoint from the directory the + // script was loaded from, so /stats/script.js fixes the second path. + async rewrites() { + return [ + { source: '/stats/script.js', destination: '/api/stats/script' }, + { source: '/stats/api/send', destination: '/api/stats/send' }, + ]; + }, images: { // Images are static assets or user-uploaded receipts; never run them through the optimizer unoptimized: true, diff --git a/src/components/Site/constants.ts b/src/components/Site/constants.ts index de9ab10..4837f0f 100644 --- a/src/components/Site/constants.ts +++ b/src/components/Site/constants.ts @@ -10,5 +10,6 @@ export const AUTHOR_URL = 'https://koushik.dev'; export const QUICK_SPLIT_PATH = '/split'; -export const UMAMI_SCRIPT_URL = 'https://umami.kdawg.dev/script.js'; +// Same-origin on purpose; see the /stats rewrites in next.config.js. +export const UMAMI_SCRIPT_URL = '/stats/script.js'; export const UMAMI_WEBSITE_ID = '03b9ce4c-7a18-4a26-8439-6a4453cde189'; diff --git a/src/env.js b/src/env.js index 5a9e6c0..85fa0b7 100644 --- a/src/env.js +++ b/src/env.js @@ -57,6 +57,13 @@ export const env = createEnv({ RECURRING_RUNNER_DISABLED: z.boolean(), /** Seconds between recurring expense ticks. Only for local demos, defaults to 5 minutes. */ RECURRING_RUNNER_INTERVAL_SECONDS: z.coerce.number().int().min(5).optional(), + /** + * Umami origin the /stats proxy talks to, server to server. Deliberately Railway's own + * domain rather than umami.kdawg.dev: that one is proxied by Cloudflare, which would stamp + * every forwarded hit with `cf-ipcountry` of our server and Umami prefers that header over + * its GeoIP lookup, putting every visitor in one country. + */ + UMAMI_UPSTREAM_URL: z.string().url().default('https://umami-production-d4b8.up.railway.app'), }, /** @@ -106,6 +113,7 @@ export const env = createEnv({ ADSENSE_DEMO: process.env.ADSENSE_DEMO === 'true', RECURRING_RUNNER_DISABLED: process.env.RECURRING_RUNNER_DISABLED === 'true', RECURRING_RUNNER_INTERVAL_SECONDS: process.env.RECURRING_RUNNER_INTERVAL_SECONDS, + UMAMI_UPSTREAM_URL: process.env.UMAMI_UPSTREAM_URL, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts index cd46743..2546a7c 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/pages/api/auth/[...nextauth].ts @@ -8,26 +8,9 @@ import { deleteVerificationTokens, normalizeEmailIdentifier, } from '~/server/auth'; +import { getClientIp } from '~/server/clientIp'; import { consumeRateLimits } from '~/server/rateLimit'; -function getClientIp(req: NextApiRequest) { - const cfIp = req.headers['cf-connecting-ip']; - if ('string' === typeof cfIp && cfIp) { - return cfIp.trim(); - } - - const forwardedFor = req.headers['x-forwarded-for']; - const firstForwarded = (Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor) - ?.split(',')[0] - ?.trim(); - - if (firstForwarded) { - return firstForwarded; - } - - return req.socket.remoteAddress ?? 'unknown'; -} - function tryNormalizeEmail(email: unknown) { if ('string' !== typeof email) { return null; diff --git a/src/pages/api/stats/script.ts b/src/pages/api/stats/script.ts new file mode 100644 index 0000000..e923626 --- /dev/null +++ b/src/pages/api/stats/script.ts @@ -0,0 +1,31 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; + +import { env } from '~/env'; + +/** + * Umami's tracker, served from our own origin via the /stats/script.js rewrite. Loaded from the + * analytics host directly it is a third-party script, which a large share of visitors block + * before the request ever leaves the browser. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if ('GET' !== req.method && 'HEAD' !== req.method) { + res.setHeader('Allow', 'GET, HEAD'); + return res.status(405).end(); + } + + try { + const upstream = await fetch(`${env.UMAMI_UPSTREAM_URL}/script.js`); + + if (!upstream.ok) { + console.error('Umami script upstream responded', upstream.status); + return res.status(502).end(); + } + + res.setHeader('Content-Type', 'text/javascript; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400'); + return res.status(200).send(await upstream.text()); + } catch (error) { + console.error('Failed to fetch the Umami script', error); + return res.status(502).end(); + } +} diff --git a/src/pages/api/stats/send.ts b/src/pages/api/stats/send.ts new file mode 100644 index 0000000..c9f7d08 --- /dev/null +++ b/src/pages/api/stats/send.ts @@ -0,0 +1,39 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; + +import { env } from '~/env'; +import { getClientIp } from '~/server/clientIp'; + +/** + * Umami's collect endpoint, reached through the /stats/api/send rewrite. The hop happens on the + * server, so Umami would otherwise attribute every hit to this app's address and fold all + * visitors into one session; CLIENT_IP_HEADER on the Umami service points at the header below. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if ('POST' !== req.method) { + res.setHeader('Allow', 'POST'); + return res.status(405).end(); + } + + // The tracker replays this token so Umami can skip re-resolving the session. + const cache = req.headers['x-umami-cache']; + + try { + const upstream = await fetch(`${env.UMAMI_UPSTREAM_URL}/api/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Umami reads device, browser and OS off the user agent, and screens out bots with it. + 'User-Agent': req.headers['user-agent'] ?? '', + 'x-splitpro-client-ip': getClientIp(req), + ...('string' === typeof cache && '' !== cache ? { 'x-umami-cache': cache } : {}), + }, + body: JSON.stringify(req.body), + }); + + res.setHeader('Cache-Control', 'no-store'); + return res.status(upstream.status).send(await upstream.text()); + } catch (error) { + console.error('Failed to forward a page view to Umami', error); + return res.status(502).end(); + } +} diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 3b89460..994897b 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -14,6 +14,7 @@ import superjson from 'superjson'; import { z, ZodError } from 'zod'; import { getServerAuthSession } from '~/server/auth'; +import { getClientIp } from '~/server/clientIp'; import { db } from '~/server/db'; /** @@ -47,13 +48,6 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => { }; }; -// splitpro.app sits behind Cloudflare, which sets cf-connecting-ip; fall back to the proxy chain. -const getClientIp = (req: CreateNextContextOptions['req']) => { - const header = req.headers['cf-connecting-ip'] ?? req.headers['x-forwarded-for']; - const value = Array.isArray(header) ? header[0] : header; - return value?.split(',')[0]?.trim() ?? req.socket.remoteAddress ?? 'unknown'; -}; - /** * This is the actual context you will use in your router. It will be used to process every request * that goes through your tRPC endpoint. diff --git a/src/server/clientIp.ts b/src/server/clientIp.ts new file mode 100644 index 0000000..859ac75 --- /dev/null +++ b/src/server/clientIp.ts @@ -0,0 +1,13 @@ +import { type NextApiRequest } from 'next'; + +/** + * splitpro.app sits behind Cloudflare, which sets cf-connecting-ip; fall back to the proxy chain. + */ +export const getClientIp = (req: NextApiRequest): string => { + const ip = [req.headers['cf-connecting-ip'], req.headers['x-forwarded-for']] + .map((header) => (Array.isArray(header) ? header[0] : header)) + .map((value) => value?.split(',')[0]?.trim()) + .find((value) => value); + + return ip ?? req.socket.remoteAddress ?? 'unknown'; +};