From 8a7192eae83349548b97585b39506c68c58bcd1b Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 14:23:23 +0700 Subject: [PATCH] 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. --- docker/backend/src/db.ts | 143 +++++++++++++++++++++++++++++++++++ docker/backend/src/server.ts | 136 ++++++++++++++++++++++++++++++++- 2 files changed, 278 insertions(+), 1 deletion(-) diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 3cfbb5b..976a7dc 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -60,9 +60,25 @@ CREATE TABLE IF NOT EXISTS photos ( bytes INTEGER NOT NULL, created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + at TEXT NOT NULL, + kind TEXT NOT NULL, + path TEXT NOT NULL, + target TEXT, + visitor TEXT NOT NULL, + user_id INTEGER, + country TEXT, + region TEXT, + city TEXT, + browser TEXT, + os TEXT, + device TEXT +); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id); CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id); +CREATE INDEX IF NOT EXISTS idx_events_at ON events(at); `); // Where a curated photo is allowed to appear on the landing page: the community @@ -531,3 +547,130 @@ export function deleteAllPhotos(): string[] { db.prepare('DELETE FROM photos').run(); return files; } + +// --- analytics ------------------------------------------------------------- +// One row per page view or feature click. Nothing that identifies a visitor is +// stored: the address is turned into a salted hash (enough to count uniques) +// and into a coarse place at insert time, then dropped. See `createEvent`. +export type EventKind = 'view' | 'click'; + +export interface EventInput { + kind: EventKind; + path: string; + target: string | null; + visitor: string; + userId: number | null; + country: string | null; + region: string | null; + city: string | null; + browser: string | null; + os: string | null; + device: string | null; +} + +export function createEvent(e: EventInput): void { + db.prepare( + `INSERT INTO events (at, kind, path, target, visitor, user_id, country, region, city, browser, os, device) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + new Date().toISOString(), + e.kind, + e.path, + e.target, + e.visitor, + e.userId, + e.country, + e.region, + e.city, + e.browser, + e.os, + e.device, + ); +} + +// One grouped count, in the shape every chart on the stats page wants. +export interface EventBucket { + key: string; + n: number; +} + +export interface EventStats { + days: number; + totals: { views: number; clicks: number; visitors: number }; + series: { date: string; views: number; clicks: number }[]; + pages: EventBucket[]; + targets: EventBucket[]; + countries: EventBucket[]; + regions: EventBucket[]; + cities: EventBucket[]; + browsers: EventBucket[]; + systems: EventBucket[]; + devices: EventBucket[]; +} + +// The columns a group-by may name. An allowlist, not interpolation: the value +// reaches a SQL string, so it must never come from the request unchecked. +const BUCKET_COLUMN = { + pages: 'path', + targets: 'target', + countries: 'country', + regions: 'region', + cities: 'city', + browsers: 'browser', + systems: 'os', + devices: 'device', +} as const; + +export function eventStats(days: number, limit = 12): EventStats { + const since = new Date(Date.now() - days * 86_400_000).toISOString(); + const grouped = (column: string, kind: EventKind | null): EventBucket[] => + db + .prepare( + `SELECT ${column} AS key, COUNT(*) AS n FROM events + WHERE at >= ? AND ${column} IS NOT NULL AND ${column} <> '' + AND (? IS NULL OR kind = ?) + GROUP BY ${column} ORDER BY n DESC, key ASC LIMIT ?`, + ) + .all(since, kind, kind, limit) as EventBucket[]; + + const totals = db + .prepare( + `SELECT + SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views, + SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks, + COUNT(DISTINCT visitor) AS visitors + FROM events WHERE at >= ?`, + ) + .get(since) as { views: number | null; clicks: number | null; visitors: number }; + + // One point per calendar day (UTC), including days with no traffic, so the + // chart's x-axis is a real timeline and not just the days that had hits. + const seen = new Map(); + for (let i = days - 1; i >= 0; i--) { + const date = new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10); + seen.set(date, { date, views: 0, clicks: 0 }); + } + const rows = db + .prepare( + `SELECT substr(at, 1, 10) AS date, + SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views, + SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks + FROM events WHERE at >= ? GROUP BY date`, + ) + .all(since) as { date: string; views: number; clicks: number }[]; + for (const row of rows) if (seen.has(row.date)) seen.set(row.date, row); + + return { + days, + totals: { views: totals.views ?? 0, clicks: totals.clicks ?? 0, visitors: totals.visitors }, + series: [...seen.values()], + pages: grouped(BUCKET_COLUMN.pages, 'view'), + targets: grouped(BUCKET_COLUMN.targets, 'click'), + countries: grouped(BUCKET_COLUMN.countries, 'view'), + regions: grouped(BUCKET_COLUMN.regions, 'view'), + cities: grouped(BUCKET_COLUMN.cities, 'view'), + browsers: grouped(BUCKET_COLUMN.browsers, 'view'), + systems: grouped(BUCKET_COLUMN.systems, 'view'), + devices: grouped(BUCKET_COLUMN.devices, 'view'), + }; +} diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 28b528b..68c92b0 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -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(); +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 { + 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' });