|
|
|
@@ -67,6 +67,65 @@ function invalidateShare() { cacheDrop('share:payload:'); }
|
|
|
|
|
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
|
|
|
|
function invalidateAll() { cacheStore.clear(); }
|
|
|
|
|
|
|
|
|
|
const BAN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
|
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
|
|
|
|
const banMemory = new Map();
|
|
|
|
|
const failMemory = new Map();
|
|
|
|
|
|
|
|
|
|
function ipOf(req) {
|
|
|
|
|
return String(req.ip || req.socket?.remoteAddress || 'unknown').slice(0, 64);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function banIP(req, reason, ms) {
|
|
|
|
|
const ip = ipOf(req);
|
|
|
|
|
const until = new Date(Date.now() + ms);
|
|
|
|
|
banMemory.set(ip, { reason, banned_until: until.toISOString() });
|
|
|
|
|
await pool.query(
|
|
|
|
|
'INSERT INTO banned_ips (ip, reason, banned_until) VALUES ($1, $2, $3) ON CONFLICT (ip) DO UPDATE SET reason = $2, banned_until = $3',
|
|
|
|
|
[ip, reason, until.toISOString()]
|
|
|
|
|
);
|
|
|
|
|
await logAudit(req, 'ip.ban', { ip, reason });
|
|
|
|
|
console.log(`IP banned: ${ip} (${reason})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ipGuard(req, res, next) {
|
|
|
|
|
const entry = banMemory.get(ipOf(req));
|
|
|
|
|
if (entry && new Date(entry.banned_until) > new Date()) {
|
|
|
|
|
return res.status(403).json({ error: 'Доступ заблокирован' });
|
|
|
|
|
}
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordFailure(req, kind, limit, ms) {
|
|
|
|
|
const ip = ipOf(req);
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
let entry = failMemory.get(kind + ':' + ip);
|
|
|
|
|
if (!entry || entry.resetAt <= now) {
|
|
|
|
|
entry = { count: 0, resetAt: now + FAIL_WINDOW_MS };
|
|
|
|
|
failMemory.set(kind + ':' + ip, entry);
|
|
|
|
|
}
|
|
|
|
|
entry.count += 1;
|
|
|
|
|
if (entry.count >= limit) {
|
|
|
|
|
failMemory.delete(kind + ':' + ip);
|
|
|
|
|
return banIP(req, kind, ms).catch(err => console.error('Ban error:', err));
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadBans() {
|
|
|
|
|
const { rows } = await pool.query('SELECT ip, reason, banned_until FROM banned_ips WHERE banned_until > now()');
|
|
|
|
|
const active = new Set();
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
active.add(r.ip);
|
|
|
|
|
banMemory.set(r.ip, { reason: r.reason, banned_until: r.banned_until });
|
|
|
|
|
}
|
|
|
|
|
for (const key of banMemory.keys()) {
|
|
|
|
|
if (!active.has(key)) banMemory.delete(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
app.set('trust proxy', 'loopback');
|
|
|
|
|
|
|
|
|
|
const apiLimiter = rateLimit({
|
|
|
|
|
windowMs: 15 * 60 * 1000,
|
|
|
|
|
max: 300,
|
|
|
|
@@ -114,6 +173,7 @@ app.use(helmet({
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
app.use(express.json({ limit: '1mb' }));
|
|
|
|
|
app.use(ipGuard);
|
|
|
|
|
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
|
|
|
|
|
const THUMB_WIDTH = 480;
|
|
|
|
|
let sharp = null;
|
|
|
|
@@ -495,6 +555,15 @@ async function ensureBranchesTable() {
|
|
|
|
|
await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureBannedIpsTable() {
|
|
|
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS banned_ips (
|
|
|
|
|
ip VARCHAR(64) PRIMARY KEY,
|
|
|
|
|
reason VARCHAR(100) NOT NULL,
|
|
|
|
|
banned_until TIMESTAMPTZ NOT NULL,
|
|
|
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
|
|
|
)`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureEntryPhotosTable() {
|
|
|
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
@@ -591,10 +660,12 @@ app.post('/api/auth/login', apiLimiter, async (req, res) => {
|
|
|
|
|
const { rows } = await pool.query('SELECT * FROM users WHERE username = $1', [username]);
|
|
|
|
|
const user = rows[0];
|
|
|
|
|
if (!user || !user.is_active) {
|
|
|
|
|
await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS);
|
|
|
|
|
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
|
|
|
|
}
|
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash || '');
|
|
|
|
|
if (!valid) {
|
|
|
|
|
await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS);
|
|
|
|
|
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
|
|
|
|
}
|
|
|
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
@@ -613,6 +684,25 @@ app.get('/api/auth/me', requireAuth, async (req, res) => {
|
|
|
|
|
res.json(safeUser(req.user));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/bans', requireAuth, requireAdmin, async (_, res) => {
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
'SELECT ip, reason, banned_until, created_at FROM banned_ips WHERE banned_until > now() ORDER BY banned_until DESC'
|
|
|
|
|
);
|
|
|
|
|
res.json(rows);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
|
|
|
|
const ip = String(req.params.ip || '').trim();
|
|
|
|
|
if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) {
|
|
|
|
|
return res.status(400).json({ error: 'Некорректный IP' });
|
|
|
|
|
}
|
|
|
|
|
await pool.query('DELETE FROM banned_ips WHERE ip = $1', [ip]);
|
|
|
|
|
banMemory.delete(ip);
|
|
|
|
|
failMemory.forEach((_, key) => { if (key.endsWith(':' + ip)) failMemory.delete(key); });
|
|
|
|
|
await logAudit(req, 'ip.unban', { ip });
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Users (admin only) ---
|
|
|
|
|
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
@@ -1452,6 +1542,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
|
|
|
|
}
|
|
|
|
|
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
|
|
|
|
if (!valid) {
|
|
|
|
|
await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS);
|
|
|
|
|
return res.status(401).json({ error: 'Неверный пароль' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -1538,7 +1629,10 @@ app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res)
|
|
|
|
|
const providedPassword = req.headers['x-share-password'] || req.query.password;
|
|
|
|
|
if (!providedPassword) return res.status(401).json({ error: 'Требуется пароль' });
|
|
|
|
|
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
|
|
|
|
if (!valid) return res.status(401).json({ error: 'Неверный пароль' });
|
|
|
|
|
if (!valid) {
|
|
|
|
|
await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS);
|
|
|
|
|
return res.status(401).json({ error: 'Неверный пароль' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const conditions = ['e.deleted_at IS NULL'];
|
|
|
|
|
const params = [];
|
|
|
|
@@ -2558,6 +2652,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
|
|
|
|
if (website) {
|
|
|
|
|
photos.forEach(removeUpload);
|
|
|
|
|
projectFiles.forEach(removeUpload);
|
|
|
|
|
await recordFailure(req, 'honeypot', 1, BAN_TTL_MS);
|
|
|
|
|
return res.status(400).json({ error: 'Spam detected' });
|
|
|
|
|
}
|
|
|
|
|
if (!student_name?.trim() || !group_id || !description?.trim()) {
|
|
|
|
@@ -3080,6 +3175,9 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|
|
|
|
try { await ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); }
|
|
|
|
|
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
|
|
|
|
|
try { await ensureAuditTable(); } catch (err) { console.error('Audit table:', err); }
|
|
|
|
|
try { await ensureBannedIpsTable(); } catch (err) { console.error('Banned IPs table:', err); }
|
|
|
|
|
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
|
|
|
|
|
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
|
|
|
|
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
|
|
|
|
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
|
|
|
|
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
|
|
|
|