diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7189b0e --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..7cce183 --- /dev/null +++ b/config/index.js @@ -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 +}; \ No newline at end of file diff --git a/modules/adminBookings/index.js b/modules/adminBookings/index.js index 9dcb4dd..b9d5363 100644 --- a/modules/adminBookings/index.js +++ b/modules/adminBookings/index.js @@ -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 }; \ No newline at end of file +module.exports = { init, setupRoutes }; \ No newline at end of file diff --git a/modules/auth/index.js b/modules/auth/index.js index 7790cd9..3b32622 100644 --- a/modules/auth/index.js +++ b/modules/auth/index.js @@ -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' }); diff --git a/modules/bookings/index.js b/modules/bookings/index.js index 9874c2e..f86a215 100644 --- a/modules/bookings/index.js +++ b/modules/bookings/index.js @@ -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 }; \ No newline at end of file +module.exports = { init, setupRoutes, validatePromocode, logHistory, calculateBasePrice }; \ No newline at end of file diff --git a/modules/reviews/index.js b/modules/reviews/index.js index df267f9..0e95cbb 100644 --- a/modules/reviews/index.js +++ b/modules/reviews/index.js @@ -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 }); diff --git a/public/js/main.js b/public/js/main.js index ebb74a5..579f05f 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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; diff --git a/server.js b/server.js index 745aea0..7766018 100644 --- a/server.js +++ b/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(reviews)", [], (err, cols) => { - if (err || !cols) return; + db.all("PRAGMA table_info(bookings)", [], (err, cols) => { + if (err) { + setupPromocodesTable(); + 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')); }); \ No newline at end of file diff --git a/test_fixes.js b/test_fixes.js new file mode 100644 index 0000000..3cf089c --- /dev/null +++ b/test_fixes.js @@ -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); \ No newline at end of file