Files
skeet_loader/server/db.js
T
2026-06-08 15:51:52 +08:00

199 lines
6.6 KiB
JavaScript

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