'use strict'; const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const adminSettings = require('./adminSettings'); const ldapClient = require('./ldapClient'); const { translit } = require('./translit'); const requestsPath = path.join(__dirname, '..', 'data', 'registration-requests.json'); const CODE_TTL_MS = 10 * 60 * 1000; const MAX_CODE_ATTEMPTS = 5; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const NAME_RE = /^[А-Яа-яЁё][А-Яа-яЁё\s-]*$/; const LOGIN_RE = /^[A-Za-z0-9._-]{1,20}$/; const PHONE_RE = /^(\+7|8)[\s(-]*(\d{3})[\s)-]*(\d{3})[\s-]*(\d{2})[\s-]*(\d{2})$/; function normalizePosition(value) { return String(value || '').trim().slice(0, 120); } function normalizePhone(value) { const trimmed = String(value || '').trim(); if (!trimmed) return ''; const m = PHONE_RE.exec(trimmed); if (!m) return null; return `+7${m[2]}${m[3]}${m[4]}${m[5]}`; } function validatePhone(value, required) { const normalized = normalizePhone(value); if (normalized === null) { const err = new Error('Некорректный номер телефона'); err.status = 400; throw err; } if (required && !normalized) { const err = new Error('Номер телефона обязателен'); err.status = 400; throw err; } return normalized; } function readRequests() { try { const parsed = JSON.parse(fs.readFileSync(requestsPath, 'utf8')); return Array.isArray(parsed) ? parsed : []; } catch (err) { return []; } } function writeRequests(requests) { fs.mkdirSync(path.dirname(requestsPath), { recursive: true }); fs.writeFileSync(requestsPath, JSON.stringify(requests, null, 2), 'utf8'); } function listRequests() { const all = readRequests(); const pending = all .filter((r) => r.phase === 'pending') .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)); const history = all .filter((r) => r.phase === 'approved' || r.phase === 'denied') .sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0)); return { pending, history }; } function getRequest(id) { return readRequests().find((r) => r.id === id) || null; } function findByEmail(email) { const needle = String(email || '').toLowerCase(); return readRequests().find((r) => r.email && r.email.toLowerCase() === needle) || null; } function findVerifyingByEmail(email) { const needle = String(email || '').toLowerCase(); return readRequests().find((r) => r.phase === 'verifying' && r.email && r.email.toLowerCase() === needle) || null; } function saveRequest(record) { const all = readRequests(); const idx = all.findIndex((r) => r.id === record.id); if (idx >= 0) all[idx] = record; else all.push(record); writeRequests(all); return record; } function generateLogin(surname, name, takenSet) { const used = new Set((takenSet || []).map((l) => String(l).toLowerCase())); const surnamePart = translit(surname); let initial = translit(String(name || '').charAt(0)); if (!initial) initial = translit(name); if (!initial) initial = 'x'; let candidate = `${surnamePart}.${initial}`; if (!candidate || used.has(candidate)) { let n = 2; while (used.has(`${surnamePart}.${initial}${n}`)) n += 1; candidate = `${surnamePart}.${initial}${n}`; } return candidate; } function validatePassword(password) { if (typeof password !== 'string' || password.length < 10) { return 'Пароль должен содержать не менее 10 символов'; } if (!/[a-zа-я]/.test(password)) return 'Пароль должен содержать строчную букву'; if (!/[A-ZА-Я]/.test(password)) return 'Пароль должен содержать заглавную букву'; if (!/[0-9]/.test(password)) return 'Пароль должен содержать цифру'; return null; } function issueCode(email) { let record = findVerifyingByEmail(email); if (!record) { record = { id: crypto.randomBytes(8).toString('hex'), phase: 'verifying', email: String(email).toLowerCase(), emailVerified: false, createdAt: Date.now() }; } const code = String(Math.floor(100000 + Math.random() * 900000)); const codeNumber = 1 + Math.floor(Math.random() * 4094); record.codeHash = crypto.createHash('sha256').update(code).digest('hex'); record.codeExpiresAt = Date.now() + CODE_TTL_MS; record.codeNumber = codeNumber; record.attempts = 0; record.emailVerified = false; saveRequest(record); return { record, code, codeNumber }; } function verifyCode(email, code) { const record = findVerifyingByEmail(email); if (!record) { const err = new Error('Код не запрашивался для этой почты'); err.status = 400; throw err; } if (record.emailVerified) return record; if (Date.now() > (record.codeExpiresAt || 0)) { const err = new Error('Код истёк, запросите новый'); err.status = 400; throw err; } if ((record.attempts || 0) >= MAX_CODE_ATTEMPTS) { const err = new Error('Слишком много попыток, запросите новый код'); err.status = 400; throw err; } const hash = crypto.createHash('sha256').update(String(code || '')).digest('hex'); if (hash !== record.codeHash) { record.attempts = (record.attempts || 0) + 1; saveRequest(record); const err = new Error('Неверный код'); err.status = 400; throw err; } record.emailVerified = true; record.codeHash = undefined; record.codeExpiresAt = undefined; record.attempts = 0; saveRequest(record); return record; } function submitRequest({ surname, name, patronymic, email, password, phone, position }, takenSet) { const record = findVerifyingByEmail(email); if (!record || !record.emailVerified) { const err = new Error('Почта не подтверждена — запросите и введите код'); err.status = 400; throw err; } const passwordError = validatePassword(password); if (passwordError) { const err = new Error(passwordError); err.status = 400; throw err; } const phoneRequired = !!adminSettings.getSettings().phoneRequired; const normalizedPhone = validatePhone(phone, phoneRequired); const fio = [surname, name, patronymic].filter(Boolean).join(' '); const login = generateLogin(surname, name, takenSet); record.surname = surname; record.name = name; record.patronymic = patronymic || ''; record.fio = fio; record.login = login; record.password = password; record.phone = normalizedPhone; record.position = normalizePosition(position); record.groups = (adminSettings.getSettings().defaultGroups || []).slice(); record.groupsEdited = false; record.type = 'registration'; record.phase = 'pending'; record.createdAt = Date.now(); record.codeHash = undefined; record.codeExpiresAt = undefined; record.attempts = 0; saveRequest(record); return record; } function validateFio(surname, name, patronymic) { if (!surname || !name) { const err = new Error('Фамилия и имя обязательны'); err.status = 400; throw err; } if (!NAME_RE.test(surname) || !NAME_RE.test(name) || (patronymic && !NAME_RE.test(patronymic))) { const err = new Error('ФИО должно содержать только русские буквы'); err.status = 400; throw err; } } async function submitPasswordChangeRequest({ surname, name, patronymic, login, email, password, phone, position }) { const record = findVerifyingByEmail(email); if (!record || !record.emailVerified) { const err = new Error('Почта не подтверждена — запросите и введите код'); err.status = 400; throw err; } const passwordError = validatePassword(password); if (passwordError) { const err = new Error(passwordError); err.status = 400; throw err; } const phoneRequired = !!adminSettings.getSettings().phoneRequired; const normalizedPhone = validatePhone(phone, phoneRequired); validateFio(surname, name, patronymic); const normalizedLogin = String(login || '').trim(); if (!normalizedLogin) { const err = new Error('Укажите логин'); err.status = 400; throw err; } const user = await ldapClient.getUser(normalizedLogin); const fio = [surname, name, patronymic].filter(Boolean).join(' '); record.surname = surname; record.name = name; record.patronymic = patronymic || ''; record.fio = fio; record.login = user.login || normalizedLogin; record.password = password; record.phone = normalizedPhone; record.position = normalizePosition(position); record.currentGroups = user.groups || []; record.type = 'password-change'; record.phase = 'pending'; record.createdAt = Date.now(); record.codeHash = undefined; record.codeExpiresAt = undefined; record.attempts = 0; saveRequest(record); return record; } function updateRequest(id, patch, takenSet) { const record = getRequest(id); if (!record) { const err = new Error('Заявка не найдена'); err.status = 404; throw err; } if (record.phase !== 'pending') { const err = new Error('Заявка уже обработана'); err.status = 409; throw err; } if (patch.email !== undefined) { if (!EMAIL_RE.test(String(patch.email))) { const err = new Error('Некорректный email'); err.status = 400; throw err; } record.email = String(patch.email).toLowerCase(); } if (patch.password !== undefined && patch.password !== '') { const passwordError = validatePassword(patch.password); if (passwordError) { const err = new Error(passwordError); err.status = 400; throw err; } record.password = patch.password; } if (patch.phone !== undefined) { const phoneRequired = !!adminSettings.getSettings().phoneRequired; record.phone = validatePhone(patch.phone, phoneRequired); } if (patch.position !== undefined) { record.position = normalizePosition(patch.position); } if (patch.login !== undefined && record.type !== 'password-change') { const login = String(patch.login).trim(); if (!login) { const err = new Error('Укажите логин'); err.status = 400; throw err; } if (!LOGIN_RE.test(login)) { const err = new Error('Логин может содержать только латинские буквы, цифры и символы . _ - (до 20 символов)'); err.status = 400; throw err; } if ((takenSet || []).some((l) => String(l).toLowerCase() === login.toLowerCase())) { const err = new Error(`Логин "${login}" уже занят`); err.status = 400; throw err; } record.login = login; } function validateName(field, value) { const trimmed = String(value || '').trim(); if (!trimmed || !NAME_RE.test(trimmed)) { const err = new Error(field === 'patronymic' ? 'Отчество должно содержать только русские буквы' : 'ФИО должно содержать только русские буквы'); err.status = 400; throw err; } return trimmed; } if (patch.surname !== undefined) record.surname = validateName('surname', patch.surname); if (patch.name !== undefined) record.name = validateName('name', patch.name); if (patch.patronymic !== undefined) record.patronymic = patch.patronymic ? validateName('patronymic', patch.patronymic) : ''; if (patch.surname !== undefined || patch.name !== undefined) { const fio = [record.surname, record.name, record.patronymic].filter(Boolean).join(' '); record.fio = fio; if (record.type !== 'password-change' && patch.login === undefined) { record.login = generateLogin(record.surname, record.name, takenSet); } } if (Array.isArray(patch.groupCNs)) { record.groups = patch.groupCNs.filter((g) => typeof g === 'string'); record.groupsEdited = true; } saveRequest(record); return record; } function markProcessed(id, patch) { const record = getRequest(id); if (!record) return null; Object.assign(record, patch, { processedAt: Date.now() }); saveRequest(record); return record; } module.exports = { NAME_RE, normalizePhone, validatePhone, normalizePosition, listRequests, getRequest, findByEmail, generateLogin, validatePassword, issueCode, verifyCode, submitRequest, submitPasswordChangeRequest, updateRequest, markProcessed };