Отзывы
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal 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
69
config/index.js
Normal 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
|
||||
};
|
||||
@@ -1,27 +1,46 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
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 JWT_SECRET;
|
||||
const ROOM_PRICES = { 'Эконом': 2500, 'Стандарт': 4000, 'VIP Люкс': 8000 };
|
||||
|
||||
function init(database, jwtSecret) {
|
||||
db = database;
|
||||
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) {
|
||||
if (!promocode) return callback(null, null);
|
||||
const now = new Date().toISOString();
|
||||
@@ -39,7 +58,6 @@ function validatePromocode(promocode, callback) {
|
||||
}
|
||||
|
||||
function getBookingsForAdmin(req, res) {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const search = req.query.search || '';
|
||||
@@ -93,7 +111,7 @@ function getBookingsForAdmin(req, res) {
|
||||
function updateBookingStatus(req, res) {
|
||||
const bookingId = parseInt(req.params.id);
|
||||
const { status } = req.body;
|
||||
const validStatuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена'];
|
||||
const validStatuses = config.STATUS_LIST;
|
||||
if (!status || !validStatuses.includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status. Valid: ' + validStatuses.join(', ') });
|
||||
}
|
||||
@@ -115,7 +133,7 @@ function updateBookingStatus(req, res) {
|
||||
function updateBookingRoom(req, res) {
|
||||
const bookingId = parseInt(req.params.id);
|
||||
const { room_type } = req.body;
|
||||
const validRooms = ['Эконом', 'Стандарт', 'VIP Люкс'];
|
||||
const validRooms = config.ROOM_TYPE_LIST;
|
||||
if (!room_type || !validRooms.includes(room_type)) {
|
||||
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 (!row) return res.status(404).json({ error: 'Booking not found' });
|
||||
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 totalPrice = basePrice - discountAmount;
|
||||
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) => {
|
||||
if (err) return res.status(500).json({ error: 'Database error' });
|
||||
const pricePerGuest = roomData ? roomData.price_per_guest : (ROOM_PRICES[roomType] || 0);
|
||||
const nights = calculateNights(finalCheckin, finalCheckout);
|
||||
const pricePerGuest = roomData ? roomData.price_per_guest : config.getRoomPrice(roomType);
|
||||
const nights = config.calculateNights(finalCheckin, finalCheckout);
|
||||
const basePrice = pricePerGuest * totalGuests * nights;
|
||||
const discountPercent = row.discount_percent || 0;
|
||||
const discountAmount = Math.round(basePrice * discountPercent / 100);
|
||||
@@ -285,12 +303,18 @@ function logHistory(bookingId, userId, userLogin, field, oldValue, newValue) {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!code) return res.status(400).json({ error: 'Promocode required' });
|
||||
validatePromocode(code, (err, promo) => {
|
||||
if (err) return res.status(500).json({ error: 'Database error' });
|
||||
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 totalPrice = basePrice - discountAmount;
|
||||
res.json({
|
||||
@@ -314,4 +338,4 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
|
||||
app.post('/api/promocodes/validate', validatePromocodeAPI);
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes, calculateNights, calculateBasePrice, validatePromocode };
|
||||
module.exports = { init, setupRoutes };
|
||||
@@ -1,6 +1,44 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
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 JWT_SECRET;
|
||||
|
||||
@@ -26,6 +64,12 @@ function requireAdmin(req, res, next) {
|
||||
}
|
||||
|
||||
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;
|
||||
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) => {
|
||||
@@ -33,6 +77,9 @@ function login(req, res) {
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const match = bcrypt.compareSync(password, user.password_hash);
|
||||
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' });
|
||||
res.json({
|
||||
token,
|
||||
@@ -56,7 +103,7 @@ function updateProfile(req, res) {
|
||||
function changePassword(req, res) {
|
||||
const { current_password, new_password } = req.body;
|
||||
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) => {
|
||||
if (err) return res.status(500).json({ error: 'Database error' });
|
||||
if (!row) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
const config = require('../../config');
|
||||
const { getRoomPrice } = config;
|
||||
|
||||
let db;
|
||||
|
||||
function init(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) {
|
||||
const ROOM_PRICES = { 'Эконом': 2500, 'Стандарт': 4000, 'VIP Люкс': 8000 };
|
||||
const pricePerNight = ROOM_PRICES[roomType] || 0;
|
||||
const nights = calculateNights(checkin, checkout);
|
||||
const pricePerNight = getRoomPrice(roomType);
|
||||
const nights = config.calculateNights(checkin, checkout);
|
||||
return pricePerNight * nights;
|
||||
}
|
||||
|
||||
@@ -102,4 +98,4 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
|
||||
app.get('/api/admin/bookings/:id/history', authenticateToken, getBookingHistory);
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes, calculateNights, calculateBasePrice, validatePromocode, logHistory };
|
||||
module.exports = { init, setupRoutes, validatePromocode, logHistory, calculateBasePrice };
|
||||
@@ -12,8 +12,7 @@ function getApprovedReviews(req, res) {
|
||||
db.all(
|
||||
`SELECT id, author_name, country, city, stars, text, created_at
|
||||
FROM reviews
|
||||
WHERE is_approved = 1
|
||||
ORDER BY created_at DESC`,
|
||||
WHERE is_approved = 1`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
@@ -25,12 +24,23 @@ function getApprovedReviews(req, res) {
|
||||
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 ? {
|
||||
count: rows.length,
|
||||
avgStars: rows.reduce((sum, r) => sum + r.stars, 0) / rows.length
|
||||
} : { 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: {} });
|
||||
}
|
||||
|
||||
const countryCodes = countries.map(c => `'${c.country_code}'`).join(',');
|
||||
const placeholders = countries.map(() => '?').join(',');
|
||||
|
||||
db.all(`
|
||||
SELECT country_code, city, COUNT(*) as count
|
||||
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
|
||||
ORDER BY count DESC
|
||||
`, (err, cities) => {
|
||||
`, countries.map(c => c.country_code), (err, cities) => {
|
||||
if (err) {
|
||||
console.error('Get popular cities error:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
|
||||
@@ -61,6 +61,11 @@ const counterObserver = new IntersectionObserver((entries) => {
|
||||
const statsSection = document.querySelector('.hero-stats');
|
||||
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
|
||||
document.querySelectorAll('.btn-book').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
@@ -80,6 +85,38 @@ function calculateNights(checkin, checkout) {
|
||||
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) {
|
||||
document.getElementById('basePriceDisplay').textContent = basePrice + ' ₽';
|
||||
document.getElementById('discountPercentDisplay').textContent = discountPercent;
|
||||
|
||||
340
server.js
340
server.js
@@ -8,15 +8,26 @@ const bcrypt = require('bcryptjs');
|
||||
const client = require('prom-client');
|
||||
require('dotenv').config();
|
||||
|
||||
const config = require('./config');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const API_KEY = process.env.HOTEL777KEY;
|
||||
const ADMIN_LOGIN = process.env.ADMIN_LOGIN;
|
||||
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_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();
|
||||
client.collectDefaultMetrics({ register });
|
||||
|
||||
@@ -63,12 +74,18 @@ const roomAvailability = new client.Gauge({
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
const loginAttempts = new client.Gauge({
|
||||
name: 'login_attempts',
|
||||
help: 'Number of login attempts',
|
||||
registers: [register]
|
||||
});
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error('FATAL: HOTEL777KEY environment variable not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '10kb' }));
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
@@ -146,106 +163,167 @@ db.serialize(() => {
|
||||
wishes TEXT,
|
||||
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 (
|
||||
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);
|
||||
const columnsToAdd = [
|
||||
'wishes TEXT',
|
||||
'status TEXT DEFAULT "новая"',
|
||||
'room_type TEXT',
|
||||
'comment TEXT',
|
||||
'base_price REAL',
|
||||
'discount_percent INTEGER DEFAULT 0',
|
||||
'discount_amount REAL DEFAULT 0',
|
||||
'total_price REAL',
|
||||
'promocode_id INTEGER'
|
||||
];
|
||||
|
||||
function addColumnSafely(columns, index) {
|
||||
if (index >= columns.length) {
|
||||
setupPromocodesTable();
|
||||
return;
|
||||
}
|
||||
|
||||
db.run(`PRAGMA foreign_keys = ON`, (err) => {
|
||||
if (err) console.error('Foreign keys error:', err.message);
|
||||
});
|
||||
db.all("PRAGMA table_info(bookings)", [], (err, cols) => {
|
||||
if (err) {
|
||||
setupPromocodesTable();
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
const columnDef = columns[index];
|
||||
const colName = columnDef.split(' ')[0];
|
||||
|
||||
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) => {
|
||||
if (err) return console.error('Check rooms count error:', err);
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
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();
|
||||
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);
|
||||
if (match) {
|
||||
try {
|
||||
const countries = eval(match[1]);
|
||||
let jsonStr = match[1];
|
||||
jsonStr = jsonStr.replace(/'/g, '"');
|
||||
const countries = JSON.parse(jsonStr);
|
||||
res.json(countries);
|
||||
} catch (e) {
|
||||
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) => {
|
||||
const countryCode = req.params.countryCode;
|
||||
|
||||
@@ -382,28 +461,15 @@ app.get('/api/cities/:countryCode', (req, res) => {
|
||||
let cities = [];
|
||||
const arrayMatch = data.match(/const\s+CITIES_\w+\s*=\s*(\[.*?\]);/s);
|
||||
if (arrayMatch) {
|
||||
cities = JSON.parse(arrayMatch[1].replace(/"/g, '"').replace(/'/g, "'"));
|
||||
}
|
||||
|
||||
if (cities.length === 0) {
|
||||
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 {
|
||||
cities = JSON.parse(safeJsonString(arrayMatch[1]));
|
||||
} catch (e) {
|
||||
const jsMatch = arrayMatch[1].replace(/'/g, '"');
|
||||
try {
|
||||
const evalResult = eval(defaultMatch[1]);
|
||||
if (Array.isArray(evalResult)) {
|
||||
cities = evalResult;
|
||||
}
|
||||
} catch (e) {}
|
||||
cities = JSON.parse(jsMatch);
|
||||
} catch (e2) {
|
||||
console.error('Failed to parse cities:', e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,12 +514,36 @@ async function convertImages() {
|
||||
}
|
||||
|
||||
convertImages().then(() => {
|
||||
app.listen(PORT, async () => {
|
||||
console.log('✅ HOTEL777KEY is', API_KEY);
|
||||
const server = app.listen(PORT, async () => {
|
||||
console.log('✅ HOTEL777KEY is set');
|
||||
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('');
|
||||
await runStartupTests(db, modules);
|
||||
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
73
test_fixes.js
Normal 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);
|
||||
Reference in New Issue
Block a user