c1c57c4f85
- 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>
67 lines
2.8 KiB
TypeScript
67 lines
2.8 KiB
TypeScript
/**
|
|
* 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);
|