feat: add anonymous view counter with most-read sidebar
- node:sqlite storage in $DATA_DIR/views.db (no native deps) - POST /api/views/<slug>/ counts once per visitor per 24h via daily-salted hash; IPs are never stored, bots are skipped - GET /api/views/top/ feeds the sidebar's featured list, falling back to frontmatter-featured and latest posts when unavailable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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);
|
||||
Reference in New Issue
Block a user