import Database from 'better-sqlite3'; import type BetterSqlite3 from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import crypto from 'crypto'; let db: BetterSqlite3.Database | null = null; let insertStmt: BetterSqlite3.Statement | null = null; const runtimeAnalyticsSalt = crypto.randomBytes(32).toString('hex'); function getAnalyticsSalt() { return process.env.ANALYTICS_SALT || runtimeAnalyticsSalt; } function getDbPath() { if (process.env.VISIT_DB_PATH) { return process.env.VISIT_DB_PATH; } const serverStorage = '/server_storage'; try { fs.mkdirSync(serverStorage, { recursive: true, mode: 0o700 }); fs.accessSync(serverStorage, fs.constants.W_OK); return path.join(serverStorage, 'visitors.db'); } catch { const localStorage = path.join(process.cwd(), '.data'); fs.mkdirSync(localStorage, { recursive: true, mode: 0o700 }); return path.join(localStorage, 'visitors.db'); } } function createVisitorId(clientAddress: string, userAgent: string) { const day = new Date().toISOString().slice(0, 10); return crypto .createHmac('sha256', getAnalyticsSalt()) .update(`${day}:${clientAddress}:${userAgent}`) .digest('hex') .slice(0, 32); } function ensureSchema(database: BetterSqlite3.Database) { database.exec(` CREATE TABLE IF NOT EXISTS visits ( id INTEGER PRIMARY KEY AUTOINCREMENT, visitor_id TEXT NOT NULL, path TEXT NOT NULL, visited_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const columns = database .prepare("PRAGMA table_info('visits')") .all() as { name: string; notnull: number }[]; const columnNames = new Set(columns.map((column) => column.name)); if (!columnNames.has('visitor_id')) { database.exec("ALTER TABLE visits ADD COLUMN visitor_id TEXT NOT NULL DEFAULT 'legacy'"); columnNames.add('visitor_id'); } if (!columnNames.has('path')) { throw new Error('visits table is missing required path column'); } return { hasLegacyIpAddress: columnNames.has('ip_address'), }; } function getDb() { if (db) return db; try { const dbPath = getDbPath(); db = new Database(dbPath); db.pragma('busy_timeout = 5000'); db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); const schema = ensureSchema(db); insertStmt = schema.hasLegacyIpAddress ? db.prepare("INSERT INTO visits (ip_address, visitor_id, path) VALUES ('redacted', ?, ?)") : db.prepare('INSERT INTO visits (visitor_id, path) VALUES (?, ?)'); return db; } catch (e) { console.error('Failed to initialize SQLite database:', e); db = null; insertStmt = null; return null; } } export function logVisit(clientAddress: string, userAgent: string, visitPath: string) { const database = getDb(); if (!database || !insertStmt) return null; const visitorId = createVisitorId(clientAddress, userAgent); insertStmt.run(visitorId, visitPath); return visitorId; }