This commit is contained in:
2026-05-03 18:59:12 +05:00
parent 28672f6006
commit f865a08d10
7 changed files with 339 additions and 33 deletions

24
auth.js
View File

@@ -1,4 +1,5 @@
const session = require('express-session');
const bcrypt = require('bcrypt');
const { db } = require('./db');
const sessionMiddleware = session({
@@ -8,23 +9,30 @@ const sessionMiddleware = session({
cookie: { secure: false, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 }
});
function ensureAdmin() {
/**
* Гарантирует существование администратора, заданного переменными окружения
* (ADMIN_LOGIN / ADMIN_PASSWORD). Пароль всегда хэшируется.
*/
async function ensureAdmin() {
const login = process.env.ADMIN_LOGIN || 'admin';
const password = process.env.ADMIN_PASSWORD || 'admin';
const saltRounds = 10;
// Таблица admins уже создана в db.js, можно сразу искать
const admin = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
const hashed = await bcrypt.hash(password, saltRounds);
if (admin) {
// Обновить пароль при каждом старте
db.prepare('UPDATE admins SET password = ? WHERE login = ?').run(password, login);
console.log(`Пароль администратора "${login}" обновлён`);
db.prepare('UPDATE admins SET password = ? WHERE login = ?').run(hashed, login);
console.log(`Пароль администратора "${login}" обновлён (хэширован)`);
} else {
// Создать запись
db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)').run(login, password);
console.log(`Администратор "${login}" создан`);
db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)').run(login, hashed);
console.log(`Администратор "${login}" создан (хэширован)`);
}
}
/**
* Middleware для проверки прав администратора.
*/
function requireAdmin(req, res, next) {
if (req.session && req.session.isAdmin) {
return next();

View File

@@ -1,20 +1,69 @@
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
let transporter = null;
let emailEnabled = true; // по умолчанию включено, при ошибке станет false
let verificationDone = false; // проверка выполнена хотя бы раз
function createTransporter() {
if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS) {
console.warn('[MAILER] SMTP не настроен (отсутствуют переменные окружения). Почтовые уведомления отключены.');
return null;
}
});
return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
}
// Функция проверки соединения с почтовым сервером
async function verifyEmailConnection() {
if (verificationDone) return emailEnabled;
transporter = createTransporter();
if (!transporter) {
emailEnabled = false;
verificationDone = true;
return false;
}
try {
await transporter.verify();
console.log('[MAILER] SMTP подключение успешно, уведомления активны.');
emailEnabled = true;
} catch (err) {
console.error('[MAILER] Ошибка подключения к SMTP:', err.message);
console.warn('[MAILER] Почтовые уведомления будут отключены до следующего перезапуска.');
emailEnabled = false;
transporter = null; // сбросим, чтобы не использовать нерабочий
} finally {
verificationDone = true;
}
return emailEnabled;
}
async function sendNotification(emails, subject, text) {
if (!emailEnabled) {
// Молча пропускаем, т.к. проверка уже показала неработоспособность
return;
}
if (!emails) return;
const recipients = emails.split(',').map(e => e.trim()).filter(Boolean);
if (recipients.length === 0) return;
// Если транспортер ещё не создан (не было вызова verify), создадим его
if (!transporter) {
transporter = createTransporter();
if (!transporter) {
emailEnabled = false;
return;
}
}
try {
await transporter.sendMail({
from: process.env.SMTP_USER,
@@ -25,6 +74,11 @@ async function sendNotification(emails, subject, text) {
console.log(`Уведомление отправлено на ${recipients.join(', ')}`);
} catch (err) {
console.error('Ошибка отправки письма:', err);
// Если при отправке возникла ошибка, считаем почту нерабочей до перезапуска
if (emailEnabled) {
console.warn('[MAILER] Отключение почтовых уведомлений из-за ошибки отправки.');
emailEnabled = false;
}
}
}
@@ -49,4 +103,4 @@ async function notifyBookingUpdate(booking, changes) {
await sendNotification(process.env.EMAIL_NOTIFY_UPDATE, subject, text);
}
module.exports = { notifyNewBooking, notifyBookingUpdate };
module.exports = { verifyEmailConnection, notifyNewBooking, notifyBookingUpdate };

26
package-lock.json generated
View File

@@ -6,6 +6,7 @@
"": {
"dependencies": {
"axios": "^1.16.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
@@ -87,6 +88,20 @@
],
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/better-sqlite3": {
"version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
@@ -1067,6 +1082,17 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",

View File

@@ -1,6 +1,7 @@
{
"dependencies": {
"axios": "^1.16.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="ru" data-title="Панель управления" data-description="Администрирование заявок гостиницы">
<html lang="ru" data-title="Панель управления" data-description="Администрирование заявок и пользователей гостиницы">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -12,6 +12,40 @@
<p>Сервис: <span id="serviceName"></span></p>
<button id="syncBtn">Запустить синхронизацию заявок</button>
<p id="syncStatus"></p>
<hr style="margin: 2rem 0;">
<h2>Управление администраторами</h2>
<table id="adminsTable">
<thead>
<tr><th>ID</th><th>Логин</th><th>Действия</th></tr>
</thead>
<tbody></tbody>
</table>
<h3>Добавить нового администратора</h3>
<form id="addAdminForm">
<label>Логин</label>
<input type="text" id="newLogin" required>
<label>Пароль</label>
<input type="password" id="newPassword" required>
<button type="submit">Добавить</button>
</form>
<!-- Модальное окно для смены пароля -->
<div id="editPasswordModal" class="modal" style="display:none;">
<div class="modal-content">
<h2>Смена пароля</h2>
<form id="editPasswordForm">
<input type="hidden" id="editAdminId">
<label>Новый пароль</label>
<input type="password" id="editPassword" required>
<button type="submit">Сохранить</button>
<button type="button" id="closePasswordModal">Отмена</button>
</form>
</div>
</div>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
@@ -21,6 +55,7 @@
if (!data.isAdmin) window.location.href = '/login.html';
});
// --- Синхронизация ---
document.getElementById('syncBtn').addEventListener('click', async () => {
const status = document.getElementById('syncStatus');
status.textContent = 'Синхронизация...';
@@ -28,6 +63,106 @@
if (res.ok) status.textContent = 'Синхронизация завершена';
else status.textContent = 'Ошибка синхронизации';
});
// --- Управление администраторами ---
const adminsTbody = document.querySelector('#adminsTable tbody');
const addForm = document.getElementById('addAdminForm');
const modal = document.getElementById('editPasswordModal');
const closeModalBtn = document.getElementById('closePasswordModal');
async function loadAdmins() {
const res = await fetch('/api/admins');
const admins = await res.json();
adminsTbody.innerHTML = '';
for (const admin of admins) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${admin.id}</td>
<td>${escapeHtml(admin.login)}</td>
<td>
<button class="changePasswordBtn" data-id="${admin.id}" data-login="${escapeHtml(admin.login)}">Сменить пароль</button>
<button class="deleteAdminBtn" data-id="${admin.id}">Удалить</button>
</td>
`;
adminsTbody.appendChild(tr);
}
// Привязываем обработчики к динамическим кнопкам
document.querySelectorAll('.changePasswordBtn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('editAdminId').value = btn.dataset.id;
document.getElementById('editPassword').value = '';
modal.style.display = 'flex';
});
});
document.querySelectorAll('.deleteAdminBtn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
if (confirm('Удалить администратора? Вы не сможете удалить самого себя и последнего админа.')) {
const res = await fetch(`/api/admins/${id}`, { method: 'DELETE' });
if (res.ok) {
loadAdmins();
} else {
const err = await res.json();
alert('Ошибка: ' + (err.error || 'неизвестная'));
}
}
});
});
}
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
const login = document.getElementById('newLogin').value.trim();
const password = document.getElementById('newPassword').value;
const res = await fetch('/api/admins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, password })
});
if (res.ok) {
alert('Администратор добавлен');
addForm.reset();
loadAdmins();
} else {
const err = await res.json();
alert('Ошибка: ' + (err.error || 'неизвестная'));
}
});
document.getElementById('editPasswordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('editAdminId').value;
const password = document.getElementById('editPassword').value;
const res = await fetch(`/api/admins/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
alert('Пароль изменён');
modal.style.display = 'none';
loadAdmins();
} else {
const err = await res.json();
alert('Ошибка: ' + (err.error || 'неизвестная'));
}
});
closeModalBtn.addEventListener('click', () => {
modal.style.display = 'none';
});
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
loadAdmins();
</script>
</body>
</html>

View File

@@ -20,4 +20,7 @@ label { font-weight: 500; }
@media (max-width: 600px) {
header { padding: 0.5rem 1rem; }
nav { flex-direction: column; }
}
}
hr { margin: 2rem 0; border: none; border-top: 1px solid #ddd; }
#adminsTable button { margin-right: 0.5rem; }
#addAdminForm { margin-top: 1rem; background: #fff; padding: 1rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); max-width: 400px; }

107
server.js
View File

@@ -1,11 +1,13 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const bcrypt = require('bcrypt');
const cron = require('node-cron');
const { db, normalizePhone, logAction } = require('./db');
const { sessionMiddleware, ensureAdmin, requireAdmin } = require('./auth');
const { syncBookings } = require('./sync');
const { notifyBookingUpdate } = require('./mailer');
const cron = require('node-cron');
const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -17,17 +19,26 @@ app.use(sessionMiddleware);
// Статика из public
app.use(express.static(path.join(__dirname, 'public')));
// Инициализация администратора
ensureAdmin();
// === Инициализация ===
// Асинхронное создание/обновление администратора из .env
(async () => {
await ensureAdmin();
})();
// Проверка работоспособности SMTP при старте (отключит уведомления при ошибке)
verifyEmailConnection().catch(err => {
console.error('Ошибка при проверке SMTP:', err);
});
// === API ===
// Вход администратора
app.post('/api/login', (req, res) => {
// Вход администратора (с хэшированием и сохранением adminId)
app.post('/api/login', async (req, res) => {
const { login, password } = req.body;
const admin = db.prepare('SELECT * FROM admins WHERE login = ? AND password = ?').get(login, password);
if (admin) {
const admin = db.prepare('SELECT id, login, password FROM admins WHERE login = ?').get(login);
if (admin && await bcrypt.compare(password, admin.password)) {
req.session.isAdmin = true;
req.session.adminId = admin.id;
res.json({ success: true });
} else {
res.status(401).json({ error: 'Неверный логин или пароль' });
@@ -45,7 +56,71 @@ app.get('/api/me', (req, res) => {
res.json({ isAdmin: !!req.session.isAdmin });
});
// Список заявок (с фильтрами, только для админа)
// --- Управление администраторами (CRUD) ---
// Получить список всех администраторов (только id и login)
app.get('/api/admins', requireAdmin, (req, res) => {
const admins = db.prepare('SELECT id, login FROM admins ORDER BY id').all();
res.json(admins);
});
// Добавить нового администратора
app.post('/api/admins', requireAdmin, async (req, res) => {
const { login, password } = req.body;
if (!login || !password) {
return res.status(400).json({ error: 'Логин и пароль обязательны' });
}
const existing = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
if (existing) {
return res.status(409).json({ error: 'Администратор с таким логином уже существует' });
}
const saltRounds = 10;
const hashed = await bcrypt.hash(password, saltRounds);
const stmt = db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)');
const info = stmt.run(login, hashed);
res.status(201).json({ id: info.lastInsertRowid, login });
});
// Изменить пароль администратора
app.put('/api/admins/:id', requireAdmin, async (req, res) => {
const { id } = req.params;
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: 'Новый пароль обязателен' });
}
const admin = db.prepare('SELECT id, login FROM admins WHERE id = ?').get(id);
if (!admin) {
return res.status(404).json({ error: 'Администратор не найден' });
}
const saltRounds = 10;
const hashed = await bcrypt.hash(password, saltRounds);
db.prepare('UPDATE admins SET password = ? WHERE id = ?').run(hashed, id);
res.json({ id: admin.id, login: admin.login });
});
// Удалить администратора
app.delete('/api/admins/:id', requireAdmin, (req, res) => {
const { id } = req.params;
// Проверка: нельзя удалить последнего администратора
const count = db.prepare('SELECT COUNT(*) as cnt FROM admins').get().cnt;
if (count <= 1) {
return res.status(400).json({ error: 'Нельзя удалить последнего администратора' });
}
// Нельзя удалить самого себя
if (req.session.adminId && req.session.adminId == id) {
return res.status(400).json({ error: 'Нельзя удалить свою учётную запись' });
}
const stmt = db.prepare('DELETE FROM admins WHERE id = ?');
const result = stmt.run(id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Администратор не найден' });
}
res.json({ success: true });
});
// --- Заявки (bookings) ---
// Список заявок с фильтрами
app.get('/api/bookings', requireAdmin, (req, res) => {
const { status, search, client_id } = req.query;
let query = 'SELECT * FROM bookings WHERE 1=1';
@@ -75,10 +150,10 @@ app.get('/api/bookings/:id', requireAdmin, (req, res) => {
res.json(booking);
});
// Обновление заявки (статус, комментарии, перепривязка клиента)
// Обновление заявки (статус, комментарий, перепривязка клиента)
app.put('/api/bookings/:id', requireAdmin, (req, res) => {
const { id } = req.params;
const { status, comments, phone } = req.body; // phone для перепривязки карточки
const { status, comments, phone } = req.body;
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
@@ -125,7 +200,9 @@ app.put('/api/bookings/:id', requireAdmin, (req, res) => {
res.json(updatedBooking);
});
// Список карточек клиентов
// --- Карточки клиентов ---
// Список клиентов
app.get('/api/clients', requireAdmin, (req, res) => {
const { search } = req.query;
let query = 'SELECT * FROM users WHERE 1=1';
@@ -139,7 +216,7 @@ app.get('/api/clients', requireAdmin, (req, res) => {
res.json(clients);
});
// Профиль клиента с его заявками (сортировка: сначала по дате убыванию, потом по статусу в алфавитном порядке)
// Профиль клиента с его заявками
app.get('/api/clients/:id', requireAdmin, (req, res) => {
const client = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!client) return res.status(404).json({ error: 'Клиент не найден' });
@@ -153,13 +230,15 @@ app.get('/api/clients/:id', requireAdmin, (req, res) => {
res.json({ client, bookings });
});
// --- Синхронизация ---
// Ручной запуск синхронизации
app.post('/api/admin/sync', requireAdmin, async (req, res) => {
await syncBookings();
res.json({ success: true, message: 'Синхронизация запущена' });
});
// Планировщик синхронизации каждые 5 минут (опционально)
// Планировщик синхронизации каждые 5 минут
cron.schedule('*/5 * * * *', () => {
console.log('Автосинхронизация...');
syncBookings().catch(console.error);