diff --git a/docs/PLAN-predsdachey.md b/docs/PLAN-predsdachey.md new file mode 100644 index 0000000..6c706cd --- /dev/null +++ b/docs/PLAN-predsdachey.md @@ -0,0 +1,56 @@ +# План доделок перед сдачей заказчику + +> 2026-06-02 + +## 1. §4.7 — Агрегация CIDR в публичном /export + +**Проблема:** публичный `/export` отдаёт сырой список, без агрегации. +ТЗ: «подсети суммаризируются (агрегируются в минимальный набор CIDR)». + +**Решение:** добавить `aggregateCIDRs()` в обработчик `server.js`. + +**Файлы:** `server.js` — одна строка. + +--- + +## 2. §4.1 — Фильтр «показать удалённые» для admin + +**Проблема:** админ не может посмотреть soft-deleted записи. + +**Решение:** +- API: поддержать `?includeDeleted=true` в `GET /api/v1/entries` +- UI: добавить чек-бокс «показать удалённые» на странице admin + +**Файлы:** `src/api/routes/entries.js`, `views/index.ejs`. + +--- + +## 3. §5 — Клиентская JS-валидация + +**Проблема:** только HTML5 `pattern`, нет проверки приватных диапазонов и нормализации на клиенте. + +**Решение:** добавить JS-функцию `validateClientIP()` в EJS-шаблон: +- Проверка приватных/запрещённых диапазонов (показывать ошибку сразу) +- Предпросмотр нормализации («будет сохранено как X.X.X.0/24») + +**Файлы:** `views/index.ejs`. + +--- + +## 4. §5 — Уведомление о нормализации в UI + +**Проблема:** API возвращает `wasNormalized`, но UI может не показывать. + +**Решение:** проверить `ui/routes/entries.js` — передаёт ли `wasNormalized` в шаблон. +Если нет — добавить. В шаблоне показать жёлтый alert. + +**Файлы:** `ui/routes/entries.js`, `views/index.ejs`. + +--- + +## Порядок: +1. §4.7 — самая простая, одна строка +2. §5 — уведомление о нормализации +3. §5 — клиентская валидация +4. §4.1 — фильтр удалённых (самая объёмная) +5. Везде: тесты, VERSION++, коммит diff --git a/package.json b/package.json index ec66df1..f7dfe26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.11", + "version": "0.5.12", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/server.js b/server.js index 2d941bd..f0759e8 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,7 @@ const q = require('./src/queries'); const { MOCK_USERS, APP_VERSION } = require('./src/config'); const { authLimiter } = require('./src/middleware/rateLimit'); const { createSessionMiddleware } = require('./src/middleware/session'); +const { aggregateCIDRs } = require('./src/validators'); const { CSP_DIRECTIVES } = require('./src/middleware/csp'); const { createUiRouter } = require('./ui/index'); @@ -83,7 +84,8 @@ async function start() { async (req, res) => { try { const cidrs = await q.getExportCIDRs(); - const text = cidrs.join('\n') + (cidrs.length ? '\n' : ''); + const aggregated = aggregateCIDRs(cidrs); + const text = aggregated.join('\n') + (aggregated.length ? '\n' : ''); res.set('Content-Type', 'text/plain; charset=utf-8'); const fname = req.query.filename || 'white-list.txt'; const disp = req.query.view === '1' ? 'inline' : 'attachment'; diff --git a/src/api/routes/entries.js b/src/api/routes/entries.js index 66271b2..c4537c2 100644 --- a/src/api/routes/entries.js +++ b/src/api/routes/entries.js @@ -62,11 +62,13 @@ function createEntriesRouter({ q }) { } // GET /api/v1/entries — список записей + лимит + // ?includeDeleted=true — показать soft-deleted (только admin) router.get('/', async (req, res) => { try { const company = await resolveCompany(req); + const includeDeleted = req.user.isAdmin && req.query.includeDeleted === 'true'; const [entries, limit] = await Promise.all([ - q.listEntries(company.id), + q.listEntries(company.id, includeDeleted), q.getLimit(company), ]); res.json({ entries, limit, used: entries.length }); diff --git a/ui/routes/entries.js b/ui/routes/entries.js index d1fa99e..8cb98b5 100644 --- a/ui/routes/entries.js +++ b/ui/routes/entries.js @@ -67,7 +67,8 @@ function createRouter() { selectedCompany = companies.find(c => c.id === companyId) || null; if (selectedCompany) { - const er = await api.get('/api/v1/entries?company=' + companyId, token); + const includeDeleted = req.query.includeDeleted === 'true' ? '&includeDeleted=true' : ''; + const er = await api.get('/api/v1/entries?company=' + companyId + includeDeleted, token); entries = er.data.entries || []; limit = er.data.limit || 15; } @@ -98,6 +99,7 @@ function createRouter() { success: req.query.success || null, lastValue: req.query.lastValue || '', lastComment: req.query.lastComment || '', + includeDeleted: req.query.includeDeleted === 'true', csrfToken: '', }); } catch (e) { @@ -107,7 +109,8 @@ function createRouter() { companies: [], selectedCompany: null, allClientIds: null, activeClientId: null, error: 'Ошибка загрузки: ' + e.message, - success: null, lastValue: '', lastComment: '', csrfToken: '', + success: null, lastValue: '', lastComment: '', + includeDeleted: false, csrfToken: '', }); } }); diff --git a/views/index.ejs b/views/index.ejs index 6d553e4..a83813e 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -198,6 +198,11 @@ style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Аудит этой компании → Лимиты + Режим: Администратор @@ -437,6 +442,104 @@ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); }); + + // ── Клиентская валидация IP (ТЗ §5) ────────────────────── + var BLOCKED = [ + { mask:'10.0.0.0/8', name:'Private (RFC1918)' }, + { mask:'172.16.0.0/12', name:'Private (RFC1918)' }, + { mask:'192.168.0.0/16', name:'Private (RFC1918)' }, + { mask:'100.64.0.0/10', name:'CGNAT' }, + { mask:'127.0.0.0/8', name:'Loopback' }, + { mask:'169.254.0.0/16', name:'Link-local' }, + { mask:'192.0.0.0/24', name:'IANA reserved' }, + { mask:'192.0.2.0/24', name:'TEST-NET-1' }, + { mask:'198.51.100.0/24', name:'TEST-NET-2' }, + { mask:'203.0.113.0/24', name:'TEST-NET-3' }, + { mask:'198.18.0.0/15', name:'Benchmarking' }, + { mask:'224.0.0.0/4', name:'Multicast' }, + { mask:'240.0.0.0/4', name:'Reserved' }, + { mask:'255.255.255.255/32',name:'Broadcast' }, + ]; + + function ipToNum(ip) { + var parts = ip.split('.'); + return ((+parts[0] * 256 + +parts[1]) * 256 + +parts[2]) * 256 + +parts[3]; + } + + function parseCIDR(val) { + var m = val.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/(\d{1,2}))?$/); + if (!m) return null; + var ip = m[1]; + var mask = m[2] ? parseInt(m[2], 10) : 32; + if (mask < 0 || mask > 32) return null; + var parts = ip.split('.'); + if (parts.some(function(p) { return +p > 255; })) return null; + return { ip: ip, mask: mask, num: ipToNum(ip) }; + } + + function checkBlocked(info) { + for (var i = 0; i < BLOCKED.length; i++) { + var b = parseCIDR(BLOCKED[i].mask); + if (!b) continue; + var maskNum = -1 << (32 - b.mask); + if ((info.num & maskNum) === (b.num & maskNum)) { + return BLOCKED[i].name + ' (' + BLOCKED[i].mask + ')'; + } + } + return null; + } + + function normalizePreview(info) { + if (info.mask === 32) return null; + var maskNum = -1 << (32 - info.mask); + var netNum = info.num & maskNum; + var o0 = (netNum >>> 24) & 255; + var o1 = (netNum >>> 16) & 255; + var o2 = (netNum >>> 8) & 255; + var o3 = netNum & 255; + var expected = o0 + '.' + o1 + '.' + o2 + '.' + o3 + '/' + info.mask; + if (expected !== info.ip + '/' + info.mask) { + return expected; + } + return null; + } + + var addForm = document.querySelector('form[action="/add"]'); + if (addForm) { + addForm.addEventListener('submit', function(e) { + var input = addForm.querySelector('input[name="value"]'); + var val = input.value.trim(); + if (!val) return; + + var info = parseCIDR(val); + if (!info) { + e.preventDefault(); + alert('Неверный формат. Ожидается: IPv4 или CIDR (например 203.0.113.10 или 203.0.113.0/24)'); + return; + } + + if (info.mask < 22) { + e.preventDefault(); + alert('Маска /' + info.mask + ' не допускается. Минимальная маска: /22'); + return; + } + + var blocked = checkBlocked(info); + if (blocked) { + e.preventDefault(); + alert('Адрес принадлежит запрещённому диапазону:\n' + blocked + '\n\nДобавление заблокировано.'); + return; + } + + var norm = normalizePreview(info); + if (norm) { + if (!confirm('Вы ввели адрес из хостовой части подсети.\n\n' + + 'Будет сохранено: ' + norm + '\n\nПродолжить?')) { + e.preventDefault(); + } + } + }); + } }());