2026-02-09 00:04:45 -05:00
|
|
|
import Database from 'better-sqlite3';
|
2026-02-09 00:17:44 -05:00
|
|
|
import type BetterSqlite3 from 'better-sqlite3';
|
2026-02-09 00:04:45 -05:00
|
|
|
import path from 'path';
|
|
|
|
|
import fs from 'fs';
|
2026-05-25 09:49:40 -04:00
|
|
|
import crypto from 'crypto';
|
2026-02-09 00:04:45 -05:00
|
|
|
|
2026-02-09 00:17:44 -05:00
|
|
|
let db: BetterSqlite3.Database | null = null;
|
|
|
|
|
let insertStmt: BetterSqlite3.Statement | null = null;
|
2026-05-25 09:49:40 -04:00
|
|
|
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'),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-02-09 00:04:45 -05:00
|
|
|
|
2026-02-09 00:17:44 -05:00
|
|
|
function getDb() {
|
|
|
|
|
if (db) return db;
|
2026-02-09 00:04:45 -05:00
|
|
|
|
2026-02-09 00:17:44 -05:00
|
|
|
try {
|
2026-05-25 09:49:40 -04:00
|
|
|
const dbPath = getDbPath();
|
2026-02-09 00:17:44 -05:00
|
|
|
db = new Database(dbPath);
|
2026-05-25 09:49:40 -04:00
|
|
|
db.pragma('busy_timeout = 5000');
|
2026-02-09 00:17:44 -05:00
|
|
|
db.pragma('journal_mode = WAL');
|
2026-05-25 09:49:40 -04:00
|
|
|
db.pragma('synchronous = NORMAL');
|
2026-02-09 00:17:44 -05:00
|
|
|
|
2026-05-25 09:49:40 -04:00
|
|
|
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 (?, ?)');
|
2026-02-09 00:17:44 -05:00
|
|
|
|
|
|
|
|
return db;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to initialize SQLite database:', e);
|
|
|
|
|
db = null;
|
|
|
|
|
insertStmt = null;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 00:04:45 -05:00
|
|
|
|
2026-05-25 09:49:40 -04:00
|
|
|
export function logVisit(clientAddress: string, userAgent: string, visitPath: string) {
|
2026-02-09 00:17:44 -05:00
|
|
|
const database = getDb();
|
2026-05-25 09:49:40 -04:00
|
|
|
if (!database || !insertStmt) return null;
|
|
|
|
|
|
|
|
|
|
const visitorId = createVisitorId(clientAddress, userAgent);
|
|
|
|
|
insertStmt.run(visitorId, visitPath);
|
|
|
|
|
return visitorId;
|
2026-02-09 00:04:45 -05:00
|
|
|
}
|