/** * Anonymous view counter on node:sqlite (server-only; used by /api/views/*). * Privacy: the IP is never stored. Each hit stores sha256(daySalt + ip + slug) for 24 h only, * the salt is random per UTC day and old salts are deleted, so hashes cannot be linked later. */ import { createHash, randomBytes } from 'node:crypto'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; const DAY_MS = 24 * 60 * 60 * 1000; let db: DatabaseSync | undefined; function open(): DatabaseSync { if (db) return db; const dir = process.env.DATA_DIR ?? './data'; mkdirSync(dir, { recursive: true }); db = new DatabaseSync(join(dir, 'views.db')); db.exec(` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS views (slug TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS hits (hash TEXT PRIMARY KEY, at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS salts (day TEXT PRIMARY KEY, salt TEXT NOT NULL); `); return db; } function daySalt(d: DatabaseSync, now: number): string { const day = new Date(now).toISOString().slice(0, 10); const row = d.prepare('SELECT salt FROM salts WHERE day = ?').get(day) as { salt: string } | undefined; if (row) return row.salt; const salt = randomBytes(32).toString('hex'); d.prepare('INSERT OR IGNORE INTO salts (day, salt) VALUES (?, ?)').run(day, salt); d.prepare('DELETE FROM salts WHERE day <> ?').run(day); return (d.prepare('SELECT salt FROM salts WHERE day = ?').get(day) as { salt: string }).salt; } /** Counts a view unless the same visitor viewed this slug in the last 24 h. Returns true if counted. */ export function recordView(slug: string, ip: string, now = Date.now()): boolean { const d = open(); const hash = createHash('sha256') .update(`${daySalt(d, now)}|${ip}|${slug}`) .digest('hex'); d.prepare('DELETE FROM hits WHERE at < ?').run(now - DAY_MS); const fresh = d.prepare('INSERT OR IGNORE INTO hits (hash, at) VALUES (?, ?)').run(hash, now); if (fresh.changes === 0) return false; d.prepare( 'INSERT INTO views (slug, count) VALUES (?, 1) ON CONFLICT(slug) DO UPDATE SET count = count + 1', ).run(slug); return true; } export function topViews(limit: number): { slug: string; count: number }[] { return open() .prepare('SELECT slug, count FROM views WHERE count > 0 ORDER BY count DESC, slug LIMIT ?') .all(limit) as { slug: string; count: number }[]; } /** First X-Forwarded-For hop (Traefik/Dokploy), else the socket address. */ export function clientIp(request: Request, fallback: string): string { return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || fallback; } export const isBot = (ua: string | null) => !ua || /bot|crawl|spider|slurp|preview|facebookexternalhit|headless|lighthouse/i.test(ua);