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
8 changes: 8 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/components/Site/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
8 changes: 8 additions & 0 deletions src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},

/**
Expand Down Expand Up @@ -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
Expand Down
19 changes: 1 addition & 18 deletions src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/pages/api/stats/script.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
39 changes: 39 additions & 0 deletions src/pages/api/stats/send.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
8 changes: 1 addition & 7 deletions src/server/api/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/server/clientIp.ts
Original file line number Diff line number Diff line change
@@ -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';
};