'use strict'; const fs = require('fs'); const path = require('path'); const { DatabaseSync } = require('node:sqlite'); const dbPath = path.join(__dirname, '..', 'data', 'journal.db'); const RETENTION_DAYS = 30; const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; let db = null; function getDb() { if (db) return db; fs.mkdirSync(path.dirname(dbPath), { recursive: true }); db = new DatabaseSync(dbPath); db.exec(` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS admin_login_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, ip TEXT NOT NULL, username TEXT NOT NULL DEFAULT '', success INTEGER NOT NULL, reason TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_admin_ts ON admin_login_log(ts); CREATE INDEX IF NOT EXISTS idx_admin_ip ON admin_login_log(ip); CREATE INDEX IF NOT EXISTS idx_admin_success ON admin_login_log(success); CREATE TABLE IF NOT EXISTS auth_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, ip TEXT NOT NULL, username TEXT NOT NULL DEFAULT '', success INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_auth_ts ON auth_log(ts); CREATE INDEX IF NOT EXISTS idx_auth_ip ON auth_log(ip); CREATE INDEX IF NOT EXISTS idx_auth_success ON auth_log(success); CREATE TABLE IF NOT EXISTS block_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, ip TEXT NOT NULL, blocked_until INTEGER NOT NULL, attempts INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_block_ts ON block_events(ts); CREATE TABLE IF NOT EXISTS active_blocks ( ip TEXT PRIMARY KEY, blocked_until INTEGER NOT NULL, attempts INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS ip_whitelist ( id INTEGER PRIMARY KEY AUTOINCREMENT, pattern TEXT NOT NULL UNIQUE, comment TEXT NOT NULL DEFAULT '' ); `); return db; } // ================= IP helpers (IPv4) ================= function ipToInt(ip) { const m = IPV4_RE.exec(String(ip || '')); if (!m) return null; const parts = m.slice(1).map(Number); if (parts.some((p) => p > 255)) return null; return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; } function isValidPattern(pattern) { const value = String(pattern || '').trim(); if (!value) return false; const parts = value.split('/'); if (parts.length === 1) { return ipToInt(value) !== null; } if (parts.length === 2) { const prefix = Number(parts[1]); return Number.isInteger(prefix) && prefix >= 0 && prefix <= 32 && ipToInt(parts[0]) !== null; } return false; } function patternContains(pattern, ipInt) { const parts = String(pattern).trim().split('/'); const base = ipToInt(parts[0]); if (base === null || ipInt === null) return false; if (parts.length === 1) return base === ipInt; const prefix = Number(parts[1]); if (prefix === 0) return true; const mask = prefix === 32 ? 0xffffffff : (0xffffffff << (32 - prefix)) >>> 0; return (base & mask) === (ipInt & mask); } // ================= Whitelist ================= function listWhitelist() { return getDb() .prepare('SELECT id, pattern, comment FROM ip_whitelist ORDER BY id') .all() .map((r) => ({ id: r.id, pattern: r.pattern, comment: r.comment })); } function addWhitelist(pattern, comment) { const value = String(pattern || '').trim(); if (!isValidPattern(value)) { const err = new Error('Некорректный IP или маска (пример: 192.168.1.0/24)'); err.status = 400; throw err; } try { const info = getDb() .prepare('INSERT INTO ip_whitelist (pattern, comment) VALUES (?, ?)') .run(value, String(comment || '').trim()); return { id: Number(info.lastInsertRowid), pattern: value, comment: String(comment || '').trim() }; } catch (err) { if (String(err.message || '').includes('UNIQUE')) { const e = new Error('Этот IP/маска уже в списке'); e.status = 409; throw e; } throw err; } } function removeWhitelist(id) { getDb().prepare('DELETE FROM ip_whitelist WHERE id = ?').run(Number(id)); } function isWhitelisted(ip) { if (!ip) return false; const ipInt = ipToInt(ip); if (ipInt === null) return false; const rows = getDb().prepare('SELECT pattern FROM ip_whitelist').all(); return rows.some((r) => patternContains(r.pattern, ipInt)); } // ================= Journals ================= function logAdminEntry({ ip, username, success, reason }) { getDb() .prepare('INSERT INTO admin_login_log (ts, ip, username, success, reason) VALUES (?, ?, ?, ?, ?)') .run(Date.now(), String(ip || ''), String(username || ''), success ? 1 : 0, String(reason || '')); } function logAuthEntry({ ip, username, success }) { getDb() .prepare('INSERT INTO auth_log (ts, ip, username, success) VALUES (?, ?, ?, ?)') .run(Date.now(), String(ip || ''), String(username || ''), success ? 1 : 0); } // ================= Blocks ================= function blockIp(ip, blockMs, attempts) { const now = Date.now(); const blockedUntil = now + blockMs; getDb() .prepare('INSERT INTO active_blocks (ip, blocked_until, attempts) VALUES (?, ?, ?) ON CONFLICT(ip) DO UPDATE SET blocked_until = excluded.blocked_until, attempts = excluded.attempts') .run(String(ip), blockedUntil, attempts); getDb() .prepare('INSERT INTO block_events (ts, ip, blocked_until, attempts) VALUES (?, ?, ?, ?)') .run(now, String(ip), blockedUntil, attempts); } function getActiveBlock(ip) { const row = getDb().prepare('SELECT ip, blocked_until, attempts FROM active_blocks WHERE ip = ?').get(String(ip || '')); if (!row) return null; return { ip: row.ip, blockedUntil: row.blocked_until, attempts: row.attempts }; } function removeActiveBlock(ip) { getDb().prepare('DELETE FROM active_blocks WHERE ip = ?').run(String(ip || '')); } function getActiveBlocks() { const now = Date.now(); const rows = getDb() .prepare('SELECT ip, blocked_until, attempts FROM active_blocks WHERE blocked_until > ? ORDER BY blocked_until') .all(now); return rows.map((r) => ({ ip: r.ip, blockedUntil: r.blocked_until, attempts: r.attempts, remainingMs: r.blocked_until - now })); } function getBlockHistory(limit) { const n = Math.min(Math.max(Number(limit) || 200, 1), 1000); return getDb() .prepare('SELECT ts, ip, blocked_until, attempts FROM block_events ORDER BY ts DESC LIMIT ?') .all(n) .map((r) => ({ ts: r.ts, ip: r.ip, blockedUntil: r.blocked_until, attempts: r.attempts })); } // ================= Queries ================= const JOURNAL_TABLES = { admin: 'admin_login_log', auth: 'auth_log' }; function queryJournal(type, filters) { const table = JOURNAL_TABLES[type]; if (!table) { const err = new Error('Неизвестный тип журнала'); err.status = 400; throw err; } const where = []; const params = []; const f = filters || {}; if (f.success === '1' || f.success === 'true' || f.success === 1) { where.push('success = 1'); } else if (f.success === '0' || f.success === 'false' || f.success === 0) { where.push('success = 0'); } if (f.from) { const from = Date.parse(String(f.from)); if (!Number.isNaN(from)) { where.push('ts >= ?'); params.push(from); } } if (f.to) { const to = Date.parse(String(f.to)); if (!Number.isNaN(to)) { where.push('ts <= ?'); params.push(to); } } if (f.ip) { where.push('ip LIKE ?'); params.push(`%${String(f.ip).trim()}%`); } if (f.username) { where.push('username LIKE ?'); params.push(`%${String(f.username).trim()}%`); } const limit = Math.min(Math.max(Number(f.limit) || 200, 1), 1000); const reasonCol = table === 'admin_login_log' ? ', reason' : ''; const sql = `SELECT id, ts, ip, username, success${reasonCol} FROM ${table} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY ts DESC LIMIT ${limit}`; return getDb().prepare(sql).all(...params).map((r) => ({ id: r.id, ts: r.ts, ip: r.ip, username: r.username, success: !!r.success, reason: r.reason || '' })); } // ================= Retention ================= function cleanup() { const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000; const database = getDb(); database.prepare('DELETE FROM admin_login_log WHERE ts < ?').run(cutoff); database.prepare('DELETE FROM auth_log WHERE ts < ?').run(cutoff); database.prepare('DELETE FROM block_events WHERE ts < ?').run(cutoff); database.prepare('DELETE FROM active_blocks WHERE blocked_until <= ?').run(Date.now()); } setInterval(cleanup, 60 * 60 * 1000).unref(); module.exports = { dbPath, RETENTION_DAYS, isValidPattern, isWhitelisted, listWhitelist, addWhitelist, removeWhitelist, logAdminEntry, logAuthEntry, blockIp, getActiveBlock, removeActiveBlock, getActiveBlocks, getBlockHistory, queryJournal };