Files
ta1sph.info/src/lib/views.ts
T
suphicakir c1c57c4f85 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>
2026-10-16 01:15:12 +03:00

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);