Отзывы

This commit is contained in:
2026-05-11 01:03:56 +05:00
parent b8d7f23ef5
commit 58a80da16f
9 changed files with 529 additions and 164 deletions

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Hotel 777 - Environment Configuration
# Copy this file to .env and fill in your values
# API Key for external access (required)
HOTEL777KEY=your-secret-api-key-here
# JWT Secret for token signing (required) - minimum 32 characters
JWT_SECRET=your-jwt-secret-min-32-characters-here
# Admin credentials (required for first run)
ADMIN_LOGIN=admin
ADMIN_PASSWORD=your-admin-password
# Monitoring credentials (optional)
MONITORING_USER=monitoring
MONITORING_PASSWORD=monitoring123
# Server port (optional, default: 3000)
# PORT=3000

69
config/index.js Normal file
View File

@@ -0,0 +1,69 @@
const ROOM_TYPES = {
economy: { id: 'Эконом', name: 'Эконом', pricePerGuest: 2500 },
standard: { id: 'Стандарт', name: 'Стандарт', pricePerGuest: 4000 },
vip: { id: 'VIP Люкс', name: 'VIP Люкс', pricePerGuest: 8000 }
};
const BOOKING_STATUSES = {
NEW: 'новая',
PAID: 'оплачена',
RESERVED: 'зарезервирована',
CHECKED_IN: 'заселена',
CHECKED_OUT: 'выехала',
CANCELLED: 'отменена'
};
const DEFAULT_ROOMS = [
{ type: ROOM_TYPES.economy.id, name: 'Эконом 1', description: 'Бюджетный номер', rooms_count: 3, single_beds: 2, double_beds: 0, has_sofa: 0, has_ac: 0, has_wifi: 1, has_shower: 1, max_guests: 2, price_per_guest: ROOM_TYPES.economy.pricePerGuest },
{ type: ROOM_TYPES.standard.id, name: 'Стандарт 1', description: 'Комфортный номер', rooms_count: 2, single_beds: 0, double_beds: 1, has_sofa: 1, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 3, price_per_guest: ROOM_TYPES.standard.pricePerGuest },
{ type: ROOM_TYPES.vip.id, name: 'VIP Люкс 1', description: 'Премиум номер', rooms_count: 1, single_beds: 0, double_beds: 1, has_sofa: 1, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 4, price_per_guest: ROOM_TYPES.vip.pricePerGuest }
];
const ROOM_PRICES = {
[ROOM_TYPES.economy.id]: ROOM_TYPES.economy.pricePerGuest,
[ROOM_TYPES.standard.id]: ROOM_TYPES.standard.pricePerGuest,
[ROOM_TYPES.vip.id]: ROOM_TYPES.vip.pricePerGuest
};
const STATUS_LIST = [
BOOKING_STATUSES.NEW,
BOOKING_STATUSES.PAID,
BOOKING_STATUSES.RESERVED,
BOOKING_STATUSES.CHECKED_IN,
BOOKING_STATUSES.CHECKED_OUT,
BOOKING_STATUSES.CANCELLED
];
const ROOM_TYPE_LIST = [
ROOM_TYPES.economy.id,
ROOM_TYPES.standard.id,
ROOM_TYPES.vip.id
];
function getRoomPrice(roomType) {
return ROOM_PRICES[roomType] || 0;
}
function calculateNights(checkin, checkout) {
const ci = new Date(checkin);
const co = new Date(checkout);
return Math.ceil((co - ci) / (1000 * 60 * 60 * 24));
}
function calculateBasePrice(roomType, checkin, checkout) {
const pricePerNight = getRoomPrice(roomType);
const nights = calculateNights(checkin, checkout);
return pricePerNight * nights;
}
module.exports = {
ROOM_TYPES,
BOOKING_STATUSES,
DEFAULT_ROOMS,
ROOM_PRICES,
STATUS_LIST,
ROOM_TYPE_LIST,
getRoomPrice,
calculateNights,
calculateBasePrice
};

View File

@@ -1,27 +1,46 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../../config');
const promocodeRateLimit = new Map();
const PROMOCODE_WINDOW = 60 * 1000;
const MAX_PROMOCODE_REQUESTS = 20;
function checkPromocodeRateLimit(ip) {
const now = Date.now();
const record = promocodeRateLimit.get(ip);
if (!record) {
promocodeRateLimit.set(ip, { count: 1, firstRequest: now });
return true;
}
if (now - record.firstRequest > PROMOCODE_WINDOW) {
promocodeRateLimit.set(ip, { count: 1, firstRequest: now });
return true;
}
if (record.count >= MAX_PROMOCODE_REQUESTS) {
return false;
}
record.count++;
return true;
}
setInterval(() => {
const now = Date.now();
for (const [ip, record] of promocodeRateLimit.entries()) {
if (now - record.firstRequest > PROMOCODE_WINDOW) {
promocodeRateLimit.delete(ip);
}
}
}, PROMOCODE_WINDOW);
let db; let db;
let JWT_SECRET; let JWT_SECRET;
const ROOM_PRICES = { 'Эконом': 2500, 'Стандарт': 4000, 'VIP Люкс': 8000 };
function init(database, jwtSecret) { function init(database, jwtSecret) {
db = database; db = database;
JWT_SECRET = jwtSecret; JWT_SECRET = jwtSecret;
} }
function calculateNights(checkin, checkout) {
const ci = new Date(checkin);
const co = new Date(checkout);
return Math.ceil((co - ci) / (1000 * 60 * 60 * 24));
}
function calculateBasePrice(roomType, checkin, checkout) {
const pricePerNight = ROOM_PRICES[roomType] || 0;
const nights = calculateNights(checkin, checkout);
return pricePerNight * nights;
}
function validatePromocode(promocode, callback) { function validatePromocode(promocode, callback) {
if (!promocode) return callback(null, null); if (!promocode) return callback(null, null);
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -39,7 +58,6 @@ function validatePromocode(promocode, callback) {
} }
function getBookingsForAdmin(req, res) { function getBookingsForAdmin(req, res) {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20; const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const search = req.query.search || ''; const search = req.query.search || '';
@@ -93,7 +111,7 @@ function getBookingsForAdmin(req, res) {
function updateBookingStatus(req, res) { function updateBookingStatus(req, res) {
const bookingId = parseInt(req.params.id); const bookingId = parseInt(req.params.id);
const { status } = req.body; const { status } = req.body;
const validStatuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена']; const validStatuses = config.STATUS_LIST;
if (!status || !validStatuses.includes(status)) { if (!status || !validStatuses.includes(status)) {
return res.status(400).json({ error: 'Invalid status. Valid: ' + validStatuses.join(', ') }); return res.status(400).json({ error: 'Invalid status. Valid: ' + validStatuses.join(', ') });
} }
@@ -115,7 +133,7 @@ function updateBookingStatus(req, res) {
function updateBookingRoom(req, res) { function updateBookingRoom(req, res) {
const bookingId = parseInt(req.params.id); const bookingId = parseInt(req.params.id);
const { room_type } = req.body; const { room_type } = req.body;
const validRooms = ['Эконом', 'Стандарт', 'VIP Люкс']; const validRooms = config.ROOM_TYPE_LIST;
if (!room_type || !validRooms.includes(room_type)) { if (!room_type || !validRooms.includes(room_type)) {
return res.status(400).json({ error: 'Invalid room type. Valid: ' + validRooms.join(', ') }); return res.status(400).json({ error: 'Invalid room type. Valid: ' + validRooms.join(', ') });
} }
@@ -123,7 +141,7 @@ function updateBookingRoom(req, res) {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Booking not found' }); if (!row) return res.status(404).json({ error: 'Booking not found' });
const oldValue = row.room_type || 'Не указан'; const oldValue = row.room_type || 'Не указан';
const basePrice = calculateBasePrice(room_type, row.checkin_date, row.checkout_date); const basePrice = config.calculateBasePrice(room_type, row.checkin_date, row.checkout_date);
const discountAmount = Math.round(basePrice * (row.discount_percent || 0) / 100); const discountAmount = Math.round(basePrice * (row.discount_percent || 0) / 100);
const totalPrice = basePrice - discountAmount; const totalPrice = basePrice - discountAmount;
db.run(`UPDATE bookings SET room_type = ?, base_price = ?, discount_amount = ?, total_price = ? WHERE id = ?`, db.run(`UPDATE bookings SET room_type = ?, base_price = ?, discount_amount = ?, total_price = ? WHERE id = ?`,
@@ -255,8 +273,8 @@ function updateBookingDetails(req, res) {
} }
db.get(`SELECT price_per_guest FROM rooms WHERE type = ? AND is_active = 1`, [roomType], (err, roomData) => { db.get(`SELECT price_per_guest FROM rooms WHERE type = ? AND is_active = 1`, [roomType], (err, roomData) => {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
const pricePerGuest = roomData ? roomData.price_per_guest : (ROOM_PRICES[roomType] || 0); const pricePerGuest = roomData ? roomData.price_per_guest : config.getRoomPrice(roomType);
const nights = calculateNights(finalCheckin, finalCheckout); const nights = config.calculateNights(finalCheckin, finalCheckout);
const basePrice = pricePerGuest * totalGuests * nights; const basePrice = pricePerGuest * totalGuests * nights;
const discountPercent = row.discount_percent || 0; const discountPercent = row.discount_percent || 0;
const discountAmount = Math.round(basePrice * discountPercent / 100); const discountAmount = Math.round(basePrice * discountPercent / 100);
@@ -285,12 +303,18 @@ function logHistory(bookingId, userId, userLogin, field, oldValue, newValue) {
} }
function validatePromocodeAPI(req, res) { function validatePromocodeAPI(req, res) {
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
if (!checkPromocodeRateLimit(clientIp)) {
return res.status(429).json({ error: 'Too many requests. Please try again later.' });
}
const { code, room_type, checkin, checkout } = req.body; const { code, room_type, checkin, checkout } = req.body;
if (!code) return res.status(400).json({ error: 'Promocode required' }); if (!code) return res.status(400).json({ error: 'Promocode required' });
validatePromocode(code, (err, promo) => { validatePromocode(code, (err, promo) => {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
if (!promo) return res.status(404).json({ error: 'Invalid or expired promocode' }); if (!promo) return res.status(404).json({ error: 'Invalid or expired promocode' });
const basePrice = calculateBasePrice(room_type, checkin, checkout); const basePrice = config.calculateBasePrice(room_type, checkin, checkout);
const discountAmount = Math.round(basePrice * promo.discount_percent / 100); const discountAmount = Math.round(basePrice * promo.discount_percent / 100);
const totalPrice = basePrice - discountAmount; const totalPrice = basePrice - discountAmount;
res.json({ res.json({
@@ -314,4 +338,4 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
app.post('/api/promocodes/validate', validatePromocodeAPI); app.post('/api/promocodes/validate', validatePromocodeAPI);
} }
module.exports = { init, setupRoutes, calculateNights, calculateBasePrice, validatePromocode }; module.exports = { init, setupRoutes };

View File

@@ -1,6 +1,44 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const loginAttempts = new Map();
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
const MAX_LOGIN_ATTEMPTS = 5;
const CLEANUP_INTERVAL = 15 * 60 * 1000;
function checkRateLimit(ip) {
const now = Date.now();
const record = loginAttempts.get(ip);
if (!record) {
loginAttempts.set(ip, { count: 1, firstAttempt: now });
return true;
}
if (now - record.firstAttempt > RATE_LIMIT_WINDOW) {
loginAttempts.set(ip, { count: 1, firstAttempt: now });
return true;
}
if (record.count >= MAX_LOGIN_ATTEMPTS) {
return false;
}
record.count++;
return true;
}
function clearRateLimit(ip) {
loginAttempts.delete(ip);
}
function cleanupLoginAttempts() {
const now = Date.now();
for (const [ip, record] of loginAttempts.entries()) {
if (now - record.firstAttempt > RATE_LIMIT_WINDOW) {
loginAttempts.delete(ip);
}
}
}
setInterval(cleanupLoginAttempts, CLEANUP_INTERVAL);
let db; let db;
let JWT_SECRET; let JWT_SECRET;
@@ -26,6 +64,12 @@ function requireAdmin(req, res, next) {
} }
function login(req, res) { function login(req, res) {
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
if (!checkRateLimit(clientIp)) {
return res.status(429).json({ error: 'Слишком много попыток входа. Попробуйте через 15 минут.' });
}
const { login, password } = req.body; const { login, password } = req.body;
if (!login || !password) return res.status(400).json({ error: 'Login and password required' }); if (!login || !password) return res.status(400).json({ error: 'Login and password required' });
db.get(`SELECT id, login, password_hash, full_name, email, role FROM users WHERE login = ?`, [login], (err, user) => { db.get(`SELECT id, login, password_hash, full_name, email, role FROM users WHERE login = ?`, [login], (err, user) => {
@@ -33,6 +77,9 @@ function login(req, res) {
if (!user) return res.status(401).json({ error: 'Invalid credentials' }); if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const match = bcrypt.compareSync(password, user.password_hash); const match = bcrypt.compareSync(password, user.password_hash);
if (!match) return res.status(401).json({ error: 'Invalid credentials' }); if (!match) return res.status(401).json({ error: 'Invalid credentials' });
clearRateLimit(clientIp);
const token = jwt.sign({ id: user.id, login: user.login, role: user.role }, JWT_SECRET, { expiresIn: '24h' }); const token = jwt.sign({ id: user.id, login: user.login, role: user.role }, JWT_SECRET, { expiresIn: '24h' });
res.json({ res.json({
token, token,
@@ -56,7 +103,7 @@ function updateProfile(req, res) {
function changePassword(req, res) { function changePassword(req, res) {
const { current_password, new_password } = req.body; const { current_password, new_password } = req.body;
if (!current_password || !new_password) return res.status(400).json({ error: 'Current and new password required' }); if (!current_password || !new_password) return res.status(400).json({ error: 'Current and new password required' });
if (new_password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' }); if (new_password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
db.get(`SELECT password_hash FROM users WHERE id = ?`, [req.user.id], (err, row) => { db.get(`SELECT password_hash FROM users WHERE id = ?`, [req.user.id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'User not found' }); if (!row) return res.status(404).json({ error: 'User not found' });

View File

@@ -1,19 +1,15 @@
const config = require('../../config');
const { getRoomPrice } = config;
let db; let db;
function init(database) { function init(database) {
db = database; db = database;
} }
function calculateNights(checkin, checkout) {
const ci = new Date(checkin);
const co = new Date(checkout);
return Math.ceil((co - ci) / (1000 * 60 * 60 * 24));
}
function calculateBasePrice(roomType, checkin, checkout) { function calculateBasePrice(roomType, checkin, checkout) {
const ROOM_PRICES = { 'Эконом': 2500, 'Стандарт': 4000, 'VIP Люкс': 8000 }; const pricePerNight = getRoomPrice(roomType);
const pricePerNight = ROOM_PRICES[roomType] || 0; const nights = config.calculateNights(checkin, checkout);
const nights = calculateNights(checkin, checkout);
return pricePerNight * nights; return pricePerNight * nights;
} }
@@ -102,4 +98,4 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
app.get('/api/admin/bookings/:id/history', authenticateToken, getBookingHistory); app.get('/api/admin/bookings/:id/history', authenticateToken, getBookingHistory);
} }
module.exports = { init, setupRoutes, calculateNights, calculateBasePrice, validatePromocode, logHistory }; module.exports = { init, setupRoutes, validatePromocode, logHistory, calculateBasePrice };

View File

@@ -12,8 +12,7 @@ function getApprovedReviews(req, res) {
db.all( db.all(
`SELECT id, author_name, country, city, stars, text, created_at `SELECT id, author_name, country, city, stars, text, created_at
FROM reviews FROM reviews
WHERE is_approved = 1 WHERE is_approved = 1`,
ORDER BY created_at DESC`,
[], [],
(err, rows) => { (err, rows) => {
if (err) { if (err) {
@@ -25,12 +24,23 @@ function getApprovedReviews(req, res) {
row.created_at = row.created_at ? new Date(row.created_at).toISOString() : null; row.created_at = row.created_at ? new Date(row.created_at).toISOString() : null;
}); });
const maxReviews = 8;
let reviews = rows;
if (rows.length > maxReviews) {
for (let i = reviews.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[reviews[i], reviews[j]] = [reviews[j], reviews[i]];
}
reviews = reviews.slice(0, maxReviews);
}
const stats = rows.length > 0 ? { const stats = rows.length > 0 ? {
count: rows.length, count: rows.length,
avgStars: rows.reduce((sum, r) => sum + r.stars, 0) / rows.length avgStars: rows.reduce((sum, r) => sum + r.stars, 0) / rows.length
} : { count: 0, avgStars: 0 }; } : { count: 0, avgStars: 0 };
res.json({ reviews: rows, stats: stats }); res.json({ reviews: reviews, stats: stats });
} }
); );
} }
@@ -205,15 +215,15 @@ function getPopularCountries(req, res) {
return res.json({ countries: [], cities: {} }); return res.json({ countries: [], cities: {} });
} }
const countryCodes = countries.map(c => `'${c.country_code}'`).join(','); const placeholders = countries.map(() => '?').join(',');
db.all(` db.all(`
SELECT country_code, city, COUNT(*) as count SELECT country_code, city, COUNT(*) as count
FROM reviews FROM reviews
WHERE is_approved = 1 AND country_code IN (${countryCodes}) WHERE is_approved = 1 AND country_code IN (${placeholders})
GROUP BY country_code, city GROUP BY country_code, city
ORDER BY count DESC ORDER BY count DESC
`, (err, cities) => { `, countries.map(c => c.country_code), (err, cities) => {
if (err) { if (err) {
console.error('Get popular cities error:', err); console.error('Get popular cities error:', err);
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });

View File

@@ -61,6 +61,11 @@ const counterObserver = new IntersectionObserver((entries) => {
const statsSection = document.querySelector('.hero-stats'); const statsSection = document.querySelector('.hero-stats');
if (statsSection) counterObserver.observe(statsSection); if (statsSection) counterObserver.observe(statsSection);
// Set min date for checkin to today
const today = new Date().toISOString().split('T')[0];
document.querySelector('[name="checkin"]').min = today;
document.querySelector('[name="checkout"]').min = today;
// Booking modal - set room name // Booking modal - set room name
document.querySelectorAll('.btn-book').forEach(btn => { document.querySelectorAll('.btn-book').forEach(btn => {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
@@ -80,6 +85,38 @@ function calculateNights(checkin, checkout) {
return Math.ceil((co - ci) / (1000 * 60 * 60 * 24)); return Math.ceil((co - ci) / (1000 * 60 * 60 * 24));
} }
function validateBookingDates() {
const checkinInput = document.querySelector('[name="checkin"]');
const checkoutInput = document.querySelector('[name="checkout"]');
const today = new Date();
today.setHours(0, 0, 0, 0);
if (checkinInput.value) {
const checkinDate = new Date(checkinInput.value);
if (checkinDate < today) {
checkinInput.setCustomValidity('Дата заезда не может быть в прошлом');
} else {
checkinInput.setCustomValidity('');
}
}
if (checkinInput.value && checkoutInput.value) {
const checkinDate = new Date(checkinInput.value);
const checkoutDate = new Date(checkoutInput.value);
if (checkoutDate <= checkinDate) {
checkoutInput.setCustomValidity('Дата выезда должна быть позже даты заезда');
} else {
checkoutInput.setCustomValidity('');
}
}
return checkinInput.checkValidity() && checkoutInput.checkValidity();
}
document.querySelector('[name="checkin"]').addEventListener('change', validateBookingDates);
document.querySelector('[name="checkout"]').addEventListener('change', validateBookingDates);
function updatePriceDisplay(basePrice, discountPercent, discountAmount, totalPrice) { function updatePriceDisplay(basePrice, discountPercent, discountAmount, totalPrice) {
document.getElementById('basePriceDisplay').textContent = basePrice + ' ₽'; document.getElementById('basePriceDisplay').textContent = basePrice + ' ₽';
document.getElementById('discountPercentDisplay').textContent = discountPercent; document.getElementById('discountPercentDisplay').textContent = discountPercent;

342
server.js
View File

@@ -8,15 +8,26 @@ const bcrypt = require('bcryptjs');
const client = require('prom-client'); const client = require('prom-client');
require('dotenv').config(); require('dotenv').config();
const config = require('./config');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const API_KEY = process.env.HOTEL777KEY; const API_KEY = process.env.HOTEL777KEY;
const ADMIN_LOGIN = process.env.ADMIN_LOGIN; const ADMIN_LOGIN = process.env.ADMIN_LOGIN;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-in-production'; const JWT_SECRET = process.env.JWT_SECRET;
const MONITORING_USER = process.env.MONITORING_USER || 'monitoring'; const MONITORING_USER = process.env.MONITORING_USER || 'monitoring';
const MONITORING_PASSWORD = process.env.MONITORING_PASSWORD || 'monitoring123'; const MONITORING_PASSWORD = process.env.MONITORING_PASSWORD || 'monitoring123';
if (!JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable not set');
process.exit(1);
}
if (JWT_SECRET === 'change-this-secret-in-production-min-32-chars') {
console.warn('WARNING: Using default JWT_SECRET. Change it in production!');
}
const register = new client.Registry(); const register = new client.Registry();
client.collectDefaultMetrics({ register }); client.collectDefaultMetrics({ register });
@@ -63,12 +74,18 @@ const roomAvailability = new client.Gauge({
registers: [register] registers: [register]
}); });
const loginAttempts = new client.Gauge({
name: 'login_attempts',
help: 'Number of login attempts',
registers: [register]
});
if (!API_KEY) { if (!API_KEY) {
console.error('FATAL: HOTEL777KEY environment variable not set'); console.error('FATAL: HOTEL777KEY environment variable not set');
process.exit(1); process.exit(1);
} }
app.use(express.json()); app.use(express.json({ limit: '10kb' }));
app.use((req, res, next) => { app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
@@ -146,106 +163,167 @@ db.serialize(() => {
wishes TEXT, wishes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`); )`);
db.run(`ALTER TABLE bookings ADD COLUMN wishes TEXT`, (err) => {});
db.run(`ALTER TABLE bookings ADD COLUMN status TEXT DEFAULT 'новая'`, (err) => {});
db.run(`ALTER TABLE bookings ADD COLUMN room_type TEXT`, (err) => {});
db.run(`ALTER TABLE bookings ADD COLUMN comment TEXT`, (err) => {});
db.run(`ALTER TABLE bookings ADD COLUMN base_price REAL`, (err) => {});
db.run(`ALTER TABLE bookings ADD COLUMN discount_percent INTEGER DEFAULT 0`, (err) => {});
db.run(`ALTER TABLE bookings ADD COLUMN discount_amount REAL DEFAULT 0`, (err) => {});
db.run(`ALTER TABLE bookings ADD COLUMN total_price REAL`, (err) => {});
db.run(`ALTER TABLE bookings ADD COLUMN promocode_id INTEGER`, (err) => {});
db.run(`CREATE TABLE IF NOT EXISTS promocodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
discount_percent INTEGER NOT NULL CHECK(discount_percent BETWEEN 1 AND 99),
valid_from DATETIME,
valid_to DATETIME,
valid_days INTEGER,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
rooms_count INTEGER DEFAULT 1,
single_beds INTEGER DEFAULT 0,
double_beds INTEGER DEFAULT 0,
has_sofa INTEGER DEFAULT 0,
has_ac INTEGER DEFAULT 0,
has_wifi INTEGER DEFAULT 0,
has_shower INTEGER DEFAULT 0,
max_guests INTEGER DEFAULT 2,
price_per_guest INTEGER NOT NULL,
image_path TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS booking_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
booking_id INTEGER NOT NULL,
user_id INTEGER,
user_login TEXT,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (booking_id) REFERENCES bookings(id)
)`);
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT,
email TEXT,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) {
console.error('CREATE TABLE settings FAILED:', err.message);
} else {
console.log('CREATE TABLE settings SUCCESS');
}
});
db.run(`CREATE TABLE IF NOT EXISTS reviews ( const columnsToAdd = [
id INTEGER PRIMARY KEY AUTOINCREMENT, 'wishes TEXT',
author_name TEXT NOT NULL, 'status TEXT DEFAULT "новая"',
country TEXT NOT NULL, 'room_type TEXT',
country_code TEXT, 'comment TEXT',
city TEXT NOT NULL, 'base_price REAL',
stars REAL NOT NULL CHECK(stars >= 0 AND stars <= 5), 'discount_percent INTEGER DEFAULT 0',
text TEXT NOT NULL, 'discount_amount REAL DEFAULT 0',
review_code TEXT NOT NULL, 'total_price REAL',
ip_address TEXT, 'promocode_id INTEGER'
is_approved INTEGER DEFAULT 0, ];
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => { function addColumnSafely(columns, index) {
if (err && !err.message.includes('already exists')) { if (index >= columns.length) {
console.error('Reviews table error:', err.message); setupPromocodesTable();
return;
} }
db.run(`PRAGMA foreign_keys = ON`, (err) => { db.all("PRAGMA table_info(bookings)", [], (err, cols) => {
if (err) console.error('Foreign keys error:', err.message); if (err) {
}); setupPromocodesTable();
return;
db.all("PRAGMA table_info(reviews)", [], (err, cols) => { }
if (err || !cols) return;
const colNames = cols.map(c => c.name); const colNames = cols.map(c => c.name);
if (!colNames.includes('country_code')) { const columnDef = columns[index];
db.run("ALTER TABLE reviews ADD COLUMN country_code TEXT", (err) => { const colName = columnDef.split(' ')[0];
if (err) console.log('Migration: country_code column (ignore if exists):', err.message);
else console.log('Migration: country_code column added'); if (!colNames.includes(colName)) {
db.run(`ALTER TABLE bookings ADD COLUMN ${columnDef}`, (err) => {
if (err && !err.message.includes('duplicate')) {
console.log('Migration note (ignore if exists):', err.message);
}
}); });
} }
addColumnSafely(columns, index + 1);
}); });
}
function setupPromocodesTable() {
db.run(`CREATE TABLE IF NOT EXISTS promocodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
discount_percent INTEGER NOT NULL CHECK(discount_percent BETWEEN 1 AND 99),
valid_from DATETIME,
valid_to DATETIME,
valid_days INTEGER,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupRoomsTable();
}
function setupRoomsTable() {
db.run(`CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
rooms_count INTEGER DEFAULT 1,
single_beds INTEGER DEFAULT 0,
double_beds INTEGER DEFAULT 0,
has_sofa INTEGER DEFAULT 0,
has_ac INTEGER DEFAULT 0,
has_wifi INTEGER DEFAULT 0,
has_shower INTEGER DEFAULT 0,
max_guests INTEGER DEFAULT 2,
price_per_guest INTEGER NOT NULL,
image_path TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupBookingHistoryTable();
}
function setupBookingHistoryTable() {
db.run(`CREATE TABLE IF NOT EXISTS booking_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
booking_id INTEGER NOT NULL,
user_id INTEGER,
user_login TEXT,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (booking_id) REFERENCES bookings(id)
)`);
setupUsersTable();
}
function setupUsersTable() {
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT,
email TEXT,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupSettingsTable();
}
function setupSettingsTable() {
db.run(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) {
console.error('CREATE TABLE settings FAILED:', err.message);
} else {
console.log('CREATE TABLE settings SUCCESS');
}
});
setupReviewsTable();
}
function setupReviewsTable() {
db.run(`CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_name TEXT NOT NULL,
country TEXT NOT NULL,
country_code TEXT,
city TEXT NOT NULL,
stars REAL NOT NULL CHECK(stars >= 0 AND stars <= 5),
text TEXT NOT NULL,
review_code TEXT NOT NULL,
ip_address TEXT,
is_approved INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err && !err.message.includes('already exists')) {
console.error('Reviews table error:', err.message);
}
db.run(`PRAGMA foreign_keys = ON`, (err) => {
if (err) console.error('Foreign keys error:', err.message);
});
db.all("PRAGMA table_info(reviews)", [], (err, cols) => {
if (err || !cols) return;
const colNames = cols.map(c => c.name);
if (!colNames.includes('country_code')) {
db.run("ALTER TABLE reviews ADD COLUMN country_code TEXT", (err) => {
if (err) console.log('Migration: country_code column (ignore if exists):', err.message);
else console.log('Migration: country_code column added');
});
}
});
});
}
addColumnSafely(columnsToAdd, 0);
db.run(`CREATE INDEX IF NOT EXISTS idx_bookings_checkin ON bookings(checkin_date)`, (err) => {
if (err) console.log('Index idx_bookings_checkin:', err.message);
});
db.run(`CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status)`, (err) => {
if (err) console.log('Index idx_bookings_status:', err.message);
}); });
}); });
@@ -285,13 +363,8 @@ function initDefaultRooms() {
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => { db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
if (err) return console.error('Check rooms count error:', err); if (err) return console.error('Check rooms count error:', err);
if (row.count > 0) return; if (row.count > 0) return;
const defaults = [
{ type: 'Эконом', name: 'Эконом 1', description: 'Бюджетный номер', rooms_count: 3, single_beds: 2, double_beds: 0, has_sofa: 0, has_ac: 0, has_wifi: 1, has_shower: 1, max_guests: 2, price_per_guest: 2500, image_path: null, is_active: 1 },
{ type: 'Стандарт', name: 'Стандарт 1', description: 'Комфортный номер', rooms_count: 2, single_beds: 0, double_beds: 1, has_sofa: 1, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 3, price_per_guest: 4000, image_path: null, is_active: 1 },
{ type: 'VIP Люкс', name: 'VIP Люкс 1', description: 'Премиум номер', rooms_count: 1, single_beds: 0, double_beds: 1, has_sofa: 1, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 4, price_per_guest: 8000, image_path: null, is_active: 1 }
];
const stmt = db.prepare(`INSERT INTO rooms (type, name, description, rooms_count, single_beds, double_beds, has_sofa, has_ac, has_wifi, has_shower, max_guests, price_per_guest, image_path, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); const stmt = db.prepare(`INSERT INTO rooms (type, name, description, rooms_count, single_beds, double_beds, has_sofa, has_ac, has_wifi, has_shower, max_guests, price_per_guest, image_path, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
defaults.forEach(r => stmt.run(r.type, r.name, r.description, r.rooms_count, r.single_beds, r.double_beds, r.has_sofa, r.has_ac, r.has_wifi, r.has_shower, r.max_guests, r.price_per_guest, r.image_path, r.is_active)); config.DEFAULT_ROOMS.forEach(r => stmt.run(r.type, r.name, r.description, r.rooms_count, r.single_beds, r.double_beds, r.has_sofa, r.has_ac, r.has_wifi, r.has_shower, r.max_guests, r.price_per_guest, r.image_path, r.is_active));
stmt.finalize(); stmt.finalize();
console.log('✅ Default rooms initialized'); console.log('✅ Default rooms initialized');
}); });
@@ -354,7 +427,9 @@ app.get('/api/countries', (req, res) => {
const match = data.match(/const\s+COUNTRIES\s*=\s*(\[.*\]);/s); const match = data.match(/const\s+COUNTRIES\s*=\s*(\[.*\]);/s);
if (match) { if (match) {
try { try {
const countries = eval(match[1]); let jsonStr = match[1];
jsonStr = jsonStr.replace(/'/g, '"');
const countries = JSON.parse(jsonStr);
res.json(countries); res.json(countries);
} catch (e) { } catch (e) {
res.status(500).json({ error: 'Parse error' }); res.status(500).json({ error: 'Parse error' });
@@ -365,6 +440,10 @@ app.get('/api/countries', (req, res) => {
}); });
}); });
function safeJsonString(str) {
return str.replace(/'/g, "'").replace(/\\"/g, '"');
}
app.get('/api/cities/:countryCode', (req, res) => { app.get('/api/cities/:countryCode', (req, res) => {
const countryCode = req.params.countryCode; const countryCode = req.params.countryCode;
@@ -382,28 +461,15 @@ app.get('/api/cities/:countryCode', (req, res) => {
let cities = []; let cities = [];
const arrayMatch = data.match(/const\s+CITIES_\w+\s*=\s*(\[.*?\]);/s); const arrayMatch = data.match(/const\s+CITIES_\w+\s*=\s*(\[.*?\]);/s);
if (arrayMatch) { if (arrayMatch) {
cities = JSON.parse(arrayMatch[1].replace(/"/g, '"').replace(/'/g, "'")); try {
} cities = JSON.parse(safeJsonString(arrayMatch[1]));
} catch (e) {
if (cities.length === 0) { const jsMatch = arrayMatch[1].replace(/'/g, '"');
const majorMatch = data.match(/CITIES_BY_CODE\s*=\s*({[\s\S]*?});/);
if (majorMatch) {
const codeMatch = majorMatch[1].match(new RegExp(`${countryCode}:\\s*\\[([^\\]]+)\\]`));
if (codeMatch) {
cities = codeMatch[1].split(',').map(c => c.trim().replace(/^["']|["']$/g, ''));
}
}
}
if (cities.length === 0) {
const defaultMatch = data.match(/CITIES_DEFAULT\s*=\s*(\[.*?\]);/s);
if (defaultMatch) {
try { try {
const evalResult = eval(defaultMatch[1]); cities = JSON.parse(jsMatch);
if (Array.isArray(evalResult)) { } catch (e2) {
cities = evalResult; console.error('Failed to parse cities:', e2);
} }
} catch (e) {}
} }
} }
@@ -448,12 +514,36 @@ async function convertImages() {
} }
convertImages().then(() => { convertImages().then(() => {
app.listen(PORT, async () => { const server = app.listen(PORT, async () => {
console.log('✅ HOTEL777KEY is', API_KEY); console.log('✅ HOTEL777KEY is set');
console.log('📊 Prometheus metrics available at: http://localhost:' + PORT + '/metrics'); console.log('📊 Prometheus metrics available at: http://localhost:' + PORT + '/metrics');
console.log('🔐 Monitoring credentials: ' + MONITORING_USER + ' / ' + (MONITORING_PASSWORD ? '***' : 'NOT SET')); const monitoringSet = MONITORING_PASSWORD ? 'configured' : 'NOT SET';
console.log('🔐 Monitoring auth: ' + monitoringSet);
console.log(''); console.log('');
await runStartupTests(db, modules); await runStartupTests(db, modules);
console.log(`✅ Hotel 777 server running on http://localhost:${PORT}`); console.log(`✅ Hotel 777 server running on http://localhost:${PORT}`);
}); });
function gracefulShutdown(signal) {
console.log(`\n${signal} received. Shutting down gracefully...`);
server.close(() => {
db.close((err) => {
if (err) {
console.error('Error closing database:', err);
} else {
console.log('Database connection closed.');
}
console.log('Server closed.');
process.exit(0);
});
});
setTimeout(() => {
console.error('Forced shutdown after timeout.');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
}); });

73
test_fixes.js Normal file
View File

@@ -0,0 +1,73 @@
const http = require('http');
function testEndpoint(path, options = {}) {
return new Promise((resolve, reject) => {
const req = http.request(`http://localhost:3000${path}`, options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve({ status: res.statusCode, data: json });
} catch {
resolve({ status: res.statusCode, data: data.slice(0, 200) });
}
});
});
req.on('error', reject);
if (options.body) req.write(options.body);
req.end();
});
}
async function runTests() {
console.log('\n=== Test 1: /api/countries (eval removed) ===');
try {
const result = await testEndpoint('/api/countries');
console.log(`Status: ${result.status}`);
if (Array.isArray(result.data)) {
console.log(`Countries count: ${result.data.length}`);
console.log(`First country: ${result.data[0]?.name || result.data[0]?.nameRu}`);
} else {
console.log('Error:', result.data);
}
} catch (e) {
console.log('Request failed:', e.message);
}
console.log('\n=== Test 2: /api/rooms (using config) ===');
try {
const result = await testEndpoint('/api/rooms');
console.log(`Status: ${result.status}`);
if (Array.isArray(result.data)) {
console.log(`Rooms count: ${result.data.length}`);
result.data.forEach(r => console.log(` - ${r.type}: ${r.price_per_guest}`));
}
} catch (e) {
console.log('Request failed:', e.message);
}
console.log('\n=== Test 3: Rate limiting on /api/promocodes/validate ===');
for (let i = 1; i <= 22; i++) {
try {
const result = await testEndpoint('/api/promocodes/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'TEST' })
});
if (i <= 3 || result.status === 429) {
console.log(`Request ${i}: status ${result.status}${result.status === 429 ? ' (rate limited)' : ''}`);
}
if (result.status === 429 && i < 22) {
console.log('Rate limit triggered at request', i);
break;
}
} catch (e) {
console.log(`Request ${i} error:`, e.message);
}
}
console.log('\n=== All tests completed ===\n');
}
setTimeout(() => runTests().then(() => process.exit(0)), 2000);