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:
@@ -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<string, { date: string; views: number; clicks: number }>();
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user