const express = require('express'); const rateLimit = require('express-rate-limit'); const router = express.Router(); const db = require('../lib/db'); const log = require('../lib/logger'); // Public API — used by landing and dashboard to load dynamic content. // These run BEFORE authMiddleware and share the small DB pool, so rate-limit them. const publicLimiter = rateLimit({ keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip, windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false, message: { error: 'Слишком много запросов' }, }); // Get all settings as key-value object router.get('/settings', publicLimiter, async (req, res) => { try { const [rows] = await db.pool.execute('SELECT setting_key, setting_value FROM settings'); const settings = {}; for (const r of rows) settings[r.setting_key] = r.setting_value; res.json(settings); } catch (err) { log.error('api /settings error', { error: err.message }); res.status(500).json({ error: 'Внутренняя ошибка' }); } }); // Get categories with their tools router.get('/tools', publicLimiter, async (req, res) => { try { const [categories] = await db.pool.execute('SELECT * FROM categories ORDER BY sort_order'); const [tools] = await db.pool.execute('SELECT * FROM tools WHERE is_enabled = 1 ORDER BY sort_order'); const result = categories.map(cat => ({ ...cat, tools: tools.filter(t => t.category_id === cat.id), })); res.json(result); } catch (err) { log.error('api /tools error', { error: err.message }); res.status(500).json({ error: 'Внутренняя ошибка' }); } }); // Get content blocks by section router.get('/content/:section', publicLimiter, async (req, res) => { try { const [rows] = await db.pool.execute( 'SELECT block_key, title, body, sort_order FROM content_blocks WHERE section = ? AND is_visible = 1 ORDER BY sort_order', [req.params.section] ); res.json(rows); } catch (err) { log.error('api /content error', { error: err.message }); res.status(500).json({ error: 'Внутренняя ошибка' }); } }); // AI explain regex (proxies to local llama.cpp) const aiLimiter = rateLimit({ keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip, windowMs: 60000, max: 10, message: { error: 'Слишком много запросов к AI' }, }); router.post('/ai/explain', express.json(), aiLimiter, async (req, res) => { const { pattern, flags } = req.body; if (!pattern || typeof pattern !== 'string' || pattern.length > 5000) { return res.status(400).json({ error: 'Паттерн пустой или слишком длинный' }); } let messages; if (flags === 'security-audit') { // Security audit mode — pattern contains full HTTP response info messages = [ { role: 'system', content: 'Ты эксперт по веб-безопасности. Отвечай на русском, markdown. Будь КРАТОК — только реальные проблемы, не перечисляй то что настроено правильно. Максимум 5 пунктов.' }, { role: 'user', content: `Проведи security-аудит этого HTTP-ответа: ${pattern} Ответь КРАТКО по структуре: ## Оценка: [КРИТИЧНО / СРЕДНЕ / ХОРОШО] ## Проблемы (только реальные, макс 5): - Проблема → как исправить (одной строкой) ## Что ОК (одной строкой, перечислением): - Какие заголовки безопасности настроены правильно НЕ разбирай каждый заголовок отдельно. НЕ повторяйся. Только практичные проблемы.` }, ]; } else { // Regex explain mode messages = [ { role: 'system', content: 'Ты эксперт по регулярным выражениям. Отвечай на русском. Формат ответа: markdown без обёртки в блок кода.' }, { role: 'user', content: `Regex: /${pattern}/${flags || 'g'} 1. Одним предложением: что это выражение ДЕЛАЕТ? Какую задачу решает? 2. Примеры строк которые СОВПАДУТ с этим выражением (2-3 примера) 3. Примеры строк которые НЕ совпадут (2-3 примера) 4. Разбор по частям (кратко, таблицей): часть паттерна → что значит` }, ]; } // SSE streaming res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 120000); // AI endpoints: PC (powerful) → RPi (fallback) const AI_ENDPOINTS = [ { url: 'http://192.168.31.100:11434/v1/chat/completions', model: 'qwen2.5:14b', name: 'PC' }, { url: 'http://127.0.0.1:8080/v1/chat/completions', model: 'qwen2.5-3b', name: 'RPi' }, ]; let response = null; let usedEndpoint = null; for (const ep of AI_ENDPOINTS) { try { const pingCtrl = new AbortController(); const pingTimeout = setTimeout(() => pingCtrl.abort(), 3000); const ping = await fetch(ep.url.replace('/v1/chat/completions', '/v1/models'), { signal: pingCtrl.signal }); clearTimeout(pingTimeout); if (!ping.ok) continue; response = await fetch(ep.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, body: JSON.stringify({ model: ep.model, messages, max_tokens: 1000, temperature: 0.3, stream: true, }), }); usedEndpoint = ep; break; } catch (pingErr) { continue; // try next endpoint } } if (!response) { res.write('data: {"error":"Все AI серверы недоступны"}\n\n'); res.end(); return; } clearTimeout(timeout); if (!response.ok) { let errMsg = 'LLM API error: ' + response.status; try { const errBody = await response.text(); console.error('LLM error:', response.status, errBody.slice(0, 300)); } catch {} res.write('data: {"error":"' + errMsg + '"}\n\n'); res.end(); return; } // Pipe SSE chunks from llama.cpp to client using async iteration try { for await (const chunk of response.body) { const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); res.write(text); } } catch (streamErr) { res.write('data: {"error":"Stream error"}\n\n'); } res.write('data: [DONE]\n\n'); res.end(); req.on('close', () => { controller.abort(); }); } catch (err) { if (err.name === 'AbortError') { res.write('data: {"error":"AI не ответил за 2 минуты"}\n\n'); } else { res.write('data: {"error":"AI недоступен"}\n\n'); } res.end(); } }); module.exports = router;