const Database = require('better-sqlite3'); const path = require('path'); let db; function initDB(dbPath) { db = new Database(dbPath || path.join(__dirname, 'skeet_server.db')); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS devices ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, display_name TEXT NOT NULL, cpu_hash TEXT NOT NULL, board_hash TEXT NOT NULL, disk_hash TEXT NOT NULL, mac_hash TEXT NOT NULL, last_login TEXT, last_ip TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS refresh_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_hash TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL, device_id INTEGER, hwid_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS login_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, device_id INTEGER, ip_address TEXT, user_agent TEXT, hwid_match_rate REAL, success INTEGER NOT NULL, reason TEXT, attempted_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS rate_limits ( key TEXT NOT NULL, action TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 1, window_start TEXT NOT NULL, PRIMARY KEY (key, action) ); `); return db; } function getDB() { if (!db) throw new Error('Database not initialized. Call initDB() first.'); return db; } // ─── Users ──────────────────────────────────────────── function getUserByUsername(username) { return getDB().prepare('SELECT * FROM users WHERE username = ?').get(username); } function getUserById(id) { return getDB().prepare('SELECT * FROM users WHERE id = ?').get(id); } function createUser(username, passwordHash, role = 'user', expiresAt = null) { return getDB().prepare( 'INSERT INTO users (username, password_hash, role, expires_at) VALUES (?, ?, ?, ?)' ).run(username, passwordHash, role, expiresAt); } // ─── Devices ────────────────────────────────────────── function findMatchingDevice(userId, cpuHash, boardHash, diskHash, macHash) { const devices = getDB().prepare( 'SELECT * FROM devices WHERE user_id = ?' ).all(userId); return devices; } function createDevice(userId, displayName, cpuHash, boardHash, diskHash, macHash, ip) { return getDB().prepare( `INSERT INTO devices (user_id, display_name, cpu_hash, board_hash, disk_hash, mac_hash, last_login, last_ip) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)` ).run(userId, displayName, cpuHash, boardHash, diskHash, macHash, ip); } function updateDeviceLogin(deviceId, ip) { getDB().prepare( 'UPDATE devices SET last_login = datetime(\'now\'), last_ip = ? WHERE id = ?' ).run(ip, deviceId); } // ─── Refresh Tokens ─────────────────────────────────── function saveRefreshToken(tokenHash, userId, deviceId, hwidHash, expiresAt) { return getDB().prepare( 'INSERT INTO refresh_tokens (token_hash, user_id, device_id, hwid_hash, expires_at) VALUES (?, ?, ?, ?, ?)' ).run(tokenHash, userId, deviceId, hwidHash, expiresAt); } function findRefreshToken(tokenHash) { return getDB().prepare( 'SELECT * FROM refresh_tokens WHERE token_hash = ?' ).get(tokenHash); } function revokeRefreshToken(tokenHash) { getDB().prepare( 'UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?' ).run(tokenHash); } // ─── Login Log ──────────────────────────────────────── function logLogin(userId, deviceId, ip, userAgent, hwidMatchRate, success, reason) { getDB().prepare( `INSERT INTO login_log (user_id, device_id, ip_address, user_agent, hwid_match_rate, success, reason) VALUES (?, ?, ?, ?, ?, ?, ?)` ).run(userId, deviceId, ip, userAgent, hwidMatchRate, success ? 1 : 0, reason); } function countRecentFailures(username, windowMinutes = 10) { const row = getDB().prepare( `SELECT COUNT(*) as cnt FROM login_log JOIN users ON login_log.user_id = users.id WHERE users.username = ? AND login_log.success = 0 AND login_log.attempted_at > datetime('now', '-' || ? || ' minutes')` ).get(username, windowMinutes); return row ? row.cnt : 0; } // ─── Rate Limits ────────────────────────────────────── function checkRateLimit(key, action, maxCount, windowSeconds) { const now = new Date().toISOString(); const db = getDB(); // UPSERT — single row per (key, action) db.prepare( `INSERT INTO rate_limits (key, action, count, window_start) VALUES (?, ?, 1, ?) ON CONFLICT(key, action) DO UPDATE SET count = CASE WHEN (unixepoch(?) - unixepoch(window_start)) > ? THEN 1 ELSE count + 1 END, window_start = CASE WHEN (unixepoch(?) - unixepoch(window_start)) > ? THEN ? ELSE window_start END` ).run(key, action, now, now, windowSeconds, now, windowSeconds, now); const row = db.prepare( 'SELECT count, window_start FROM rate_limits WHERE key = ? AND action = ?' ).get(key, action); if (!row) return { allowed: true, remaining: maxCount }; const windowAge = (Date.now() - new Date(row.window_start + 'Z').getTime()) / 1000; if (windowAge > windowSeconds) return { allowed: true, remaining: maxCount }; return { allowed: row.count <= maxCount, remaining: Math.max(0, maxCount - row.count), retryAfter: row.count > maxCount ? Math.ceil(windowSeconds - windowAge) : 0 }; } module.exports = { initDB, getDB, getUserByUsername, getUserById, createUser, findMatchingDevice, createDevice, updateDeviceLogin, saveRefreshToken, findRefreshToken, revokeRefreshToken, logLogin, countRecentFailures, checkRateLimit };