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();
+ }
+ }
+ });
+ }
}());