api: count page views and feature clicks

One events row per beacon: the kind, the page, the clicked control, a salted
hash of the address (never the address), its coarse place, and the browser,
system and device read off the UA. A new public POST /api/events writes it and
always answers 204; GET /api/admin/stats reads it back as totals, a day series
and one grouped breakdown per dimension, behind the admin gate.
This commit is contained in:
2026-09-18 14:23:23 +07:00
parent 1c4cdf8af1
commit 8a7192eae8
2 changed files with 278 additions and 1 deletions
+135 -1
View File
@@ -1,5 +1,5 @@
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { randomBytes } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { basename } from 'node:path';
import {
@@ -10,6 +10,7 @@ import {
SESSION_MAX_AGE_S,
DUMMY_HASH,
countPhotos,
createEvent,
createPhoto,
createRecipe,
createSession,
@@ -20,6 +21,7 @@ import {
deleteRecipe,
deleteSession,
deleteUser,
eventStats,
findUserByEmail,
findUserById,
isPhotoSlot,
@@ -45,6 +47,7 @@ import {
type PhotoMeta,
type Recipe,
type User,
type EventKind,
} from './db';
const PORT = Number(process.env.PORT || 3000);
@@ -216,9 +219,129 @@ function auth(req: FastifyRequest): User | undefined {
return token ? sessionUser(token) : undefined;
}
// ---- analytics ------------------------------------------------------------
// The page counter. It stores nothing that identifies a visitor: the address
// becomes a salted hash (enough to count uniques) and a coarse place, then it
// is dropped. A full UA/GeoIP database would be a dependency for numbers nobody
// acts on at this size, so both readings stay deliberately plain.
const MAX_PATH = 120;
const MAX_TARGET = 60;
const isEventKind = (v: unknown): v is EventKind => v === 'view' || v === 'click';
// The address the visitor actually used. nginx (and the proxy in front of it)
// append to X-Forwarded-For, so the left-most entry is the client; `req.ip` is
// the fallback for a direct call.
function clientIp(req: FastifyRequest): string {
const chain = req.headers['x-forwarded-for'];
const raw = Array.isArray(chain) ? chain[0] : chain;
return raw?.split(',')[0]?.trim() || req.ip || '';
}
const STATS_SALT = process.env.STATS_SALT ?? 'recipescam-analytics';
const visitorOf = (ip: string) => createHash('sha256').update(`${STATS_SALT}:${ip}`).digest('hex').slice(0, 16);
// Family + major version, the OS, and the form factor. Order matters: Edge and
// Opera also claim Chrome, and Safari claims nothing else.
const BROWSERS: [string, RegExp][] = [
['Edge', /Edg\/(\d+)/],
['Opera', /OPR\/(\d+)/],
['Samsung Internet', /SamsungBrowser\/(\d+)/],
['Firefox', /Firefox\/(\d+)/],
['Chrome', /Chrome\/(\d+)/],
// Safari's own token comes last and may sit past "Mobile/…", which is why the
// gap between the version and the name is not just digits.
['Safari', /Version\/(\d+)[^)]* Safari/],
];
const SYSTEMS: [string, RegExp][] = [
['Windows', /Windows NT/],
['Android', /Android/],
['iOS', /iPhone|iPad|iPod/],
['macOS', /Mac OS X/],
['Linux', /Linux|X11/],
];
function parseUa(ua: string): { browser: string | null; os: string | null; device: string } {
const u = ua.toLowerCase();
const device = /bot|crawler|spider|httpclient|curl|wget|python-requests/.test(u)
? 'bot'
: /ipad|tablet|android(?!.*mobile)/.test(u)
? 'tablet'
: /mobi|iphone|ipod|android/.test(u)
? 'mobile'
: 'desktop';
const match = (table: [string, RegExp][]) => {
for (const [name, re] of table) {
const m = re.exec(ua);
// Some patterns carry a version group, some only name the family.
if (m) return m[1] ? `${name} ${m[1]}` : name;
}
return null;
};
return { browser: match(BROWSERS), os: match(SYSTEMS), device };
}
interface GeoPlace {
country: string;
region: string;
city: string;
}
// Resolved at insert, cached per address. ip-api's free endpoint needs no key;
// when it is unreachable or rate-limited the row keeps a null place and the
// stats page shows "unknown" rather than failing the request.
const geoCache = new Map<string, GeoPlace | null>();
const PRIVATE_IP = /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|f[cd]|localhost)/i;
async function lookupGeo(ip: string): Promise<GeoPlace | null> {
if (!ip || PRIVATE_IP.test(ip)) return null;
const hit = geoCache.get(ip);
if (hit !== undefined) return hit;
if (geoCache.size > 2000) geoCache.clear();
let place: GeoPlace | null = null;
try {
const res = await fetch(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city`,
{ signal: AbortSignal.timeout(1500) },
);
const b = (await res.json()) as { status?: string; country?: string; regionName?: string; city?: string };
if (b.status === 'success') place = { country: b.country ?? '', region: b.regionName ?? '', city: b.city ?? '' };
} catch {
// Offline, blocked or rate-limited: a missing place, not a failed visit.
}
geoCache.set(ip, place);
return place;
}
// ---- routes ----
app.get('/api/health', async () => ({ ok: true }));
// The counter's one write. Public and unauthenticated by design: it is fired by
// a beacon from every page, so it must never be a way to probe who is signed in
// — it answers 204 whatever happens and echoes nothing back.
const allowTrack = limiter(300, 60_000);
app.post('/api/events', async (req, reply) => {
const b = bodyOf(req);
const ip = clientIp(req);
if (!b || !isEventKind(b.kind) || !allowTrack(ip || 'unknown')) return reply.status(204).send();
const text = (v: unknown, max: number) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
const ua = parseUa(typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '');
const geo = await lookupGeo(ip);
createEvent({
kind: b.kind,
path: text(b.path, MAX_PATH) || '/',
target: text(b.target, MAX_TARGET) || null,
visitor: visitorOf(ip || 'unknown'),
userId: auth(req)?.id ?? null,
country: geo?.country || null,
region: geo?.region || null,
city: geo?.city || null,
browser: ua.browser,
os: ua.os,
device: ua.device,
});
return reply.status(204).send();
});
app.post('/api/auth/signup', async (req, reply) => {
const b = bodyOf(req);
if (!b) return reply.status(400).send({ error: 'invalid body' });
@@ -614,6 +737,17 @@ app.get('/api/admin/photos', async (req, reply) => {
return reply.status(200).send({ photos: listPhotosWithOwner() });
});
// The traffic screen's one read: page views, feature clicks and how they break
// down by page, feature, place, browser, system and device. `days` is clamped
// rather than rejected — a bad range must not cost the whole page.
app.get('/api/admin/stats', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
const asked = Number((req.query as { days?: string }).days);
const days = Number.isFinite(asked) ? Math.min(365, Math.max(1, Math.floor(asked))) : 30;
return reply.status(200).send(eventStats(days));
});
app.delete<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });