const express = require('express'); const path = require('path'); const fs = require('fs'); const sqlite3 = require('sqlite3').verbose(); const sharp = require('sharp'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const client = require('prom-client'); const multer = require('multer'); 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; const MONITORING_USER = process.env.MONITORING_USER || 'monitoring'; const MONITORING_PASSWORD = process.env.MONITORING_PASSWORD || 'monitoring123'; const uploadsDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); const roomsUploadsDir = path.join(uploadsDir, 'rooms'); if (!fs.existsSync(roomsUploadsDir)) fs.mkdirSync(roomsUploadsDir, { recursive: true }); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, roomsUploadsDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); const timestamp = Date.now(); cb(null, `${timestamp}${ext}`); } }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const allowed = ['.jpg', '.jpeg', '.png', '.webp']; const ext = path.extname(file.originalname).toLowerCase(); if (allowed.includes(ext)) cb(null, true); else cb(new Error('Только изображения: jpg, jpeg, png, webp')); } }); 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 }); const httpRequestsTotal = new client.Counter({ name: 'http_requests_total', help: 'Total number of HTTP requests', labelNames: ['method', 'path', 'status'], registers: [register] }); const httpRequestDuration = new client.Histogram({ name: 'http_request_duration_seconds', help: 'Duration of HTTP requests in seconds', labelNames: ['method', 'path', 'status'], buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5], registers: [register] }); const activeConnections = new client.Gauge({ name: 'active_connections', help: 'Number of active connections', registers: [register] }); const dbQueryDuration = new client.Histogram({ name: 'db_query_duration_seconds', help: 'Duration of database queries in seconds', labelNames: ['operation'], buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1], registers: [register] }); const bookingsTotal = new client.Gauge({ name: 'bookings_total', help: 'Total number of bookings', labelNames: ['status'], registers: [register] }); const roomAvailability = new client.Gauge({ name: 'room_availability', help: 'Number of available rooms by type', labelNames: ['type'], 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({ 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'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (req.method === 'OPTIONS') return res.sendStatus(200); next(); }); app.use(express.static(path.join(__dirname, 'public'))); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use((req, res, next) => { const start = Date.now(); activeConnections.inc(); res.on('finish', () => { const duration = (Date.now() - start) / 1000; const path = req.route ? req.route.path : req.path; httpRequestsTotal.inc({ method: req.method, path: path, status: res.statusCode }); httpRequestDuration.observe({ method: req.method, path: path, status: res.statusCode }, duration); activeConnections.dec(); }); next(); }); app.use('/metrics', (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) { res.set('WWW-Authenticate', 'Basic realm="Monitoring"'); return res.status(401).send('Authentication required'); } const auth = new Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':'); const user = auth[0]; const pass = auth[1]; if (user === MONITORING_USER && pass === MONITORING_PASSWORD) { res.set('Content-Type', register.contentType); register.metrics().then(metrics => res.end(metrics)).catch(err => res.status(500).send(err.message)); } else { res.set('WWW-Authenticate', 'Basic realm="Monitoring"'); return res.status(401).send('Authentication required'); } }); function updateMetrics() { db.all(`SELECT status, COUNT(*) as count FROM bookings GROUP BY status`, [], (err, rows) => { if (!err && rows) { rows.forEach(row => { bookingsTotal.set({ status: row.status || 'unknown' }, row.count); }); } }); db.all(`SELECT type, rooms_count FROM rooms WHERE is_active = 1`, [], (err, rows) => { if (!err && rows) { rows.forEach(row => { roomAvailability.set({ type: row.type }, row.rooms_count); }); } }); } setInterval(updateMetrics, 30000); setTimeout(updateMetrics, 5000); const dataDir = path.join(__dirname, 'data'); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir); const dbPath = path.join(dataDir, 'bookings.db'); const db = new sqlite3.Database(dbPath); db.serialize(() => { db.run(`CREATE TABLE IF NOT EXISTS bookings ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT NOT NULL, adults INTEGER NOT NULL, children INTEGER NOT NULL, checkin_date TEXT NOT NULL, checkout_date TEXT NOT NULL, wishes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); 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.all("PRAGMA table_info(bookings)", [], (err, cols) => { if (err) { setupPromocodesTable(); return; } const colNames = cols.map(c => c.name); 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, area_sqm INTEGER DEFAULT 20, max_guests INTEGER DEFAULT 2, furniture TEXT DEFAULT '[]', amenities TEXT DEFAULT '[]', floors TEXT DEFAULT '[]', price_per_night INTEGER NOT NULL, image_path TEXT, extra_beds INTEGER DEFAULT 0, extra_bed_price INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); db.all("PRAGMA table_info(rooms)", [], (err, cols) => { if (err) { setupBookingHistoryTable(); return; } const colNames = cols.map(c => c.name); const migrations = [ ['area_sqm', 'INTEGER DEFAULT 20'], ['furniture', "TEXT DEFAULT '[]'"], ['amenities', "TEXT DEFAULT '[]'"], ['floors', "TEXT DEFAULT '[]'"], ['extra_beds', 'INTEGER DEFAULT 0'], ['extra_bed_price', 'INTEGER DEFAULT 0'] ]; let pending = migrations.length; if (pending === 0) { migratePricePerNight(); return; } migrations.forEach(([colName, colDef], i) => { if (!colNames.includes(colName)) { db.run(`ALTER TABLE rooms ADD COLUMN ${colName} ${colDef}`, (err) => { if (err && !err.message.includes('duplicate') && !err.message.includes('NOT NULL')) { console.log('Room migration note:', err.message); } }); } if (--pending === 0) migratePricePerNight(); }); }); } function migratePricePerNight() { db.all("PRAGMA table_info(rooms)", [], (err, cols) => { if (err) { setupBookingHistoryTable(); return; } const colNames = cols.map(c => c.name); if (!colNames.includes('price_per_night')) { db.run(`ALTER TABLE rooms ADD COLUMN price_per_night INTEGER DEFAULT 0`, (err) => { if (err && !err.message.includes('duplicate') && !err.message.includes('NOT NULL')) { console.log('Room migration note (add price_per_night):', err.message); } }); } db.run(`CREATE TABLE rooms_backup AS SELECT id, type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active, created_at FROM rooms`, (err) => { if (err) { console.log('Room backup failed:', err.message); setupBookingHistoryTable(); return; } db.run(`DROP TABLE rooms`, (err) => { if (err) { console.log('Room drop failed:', err.message); setupBookingHistoryTable(); return; } db.run(`CREATE TABLE rooms (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, name TEXT NOT NULL, description TEXT, rooms_count INTEGER DEFAULT 1, area_sqm INTEGER DEFAULT 20, max_guests INTEGER DEFAULT 2, furniture TEXT DEFAULT '[]', amenities TEXT DEFAULT '[]', floors TEXT DEFAULT '[]', price_per_night INTEGER NOT NULL, image_path TEXT, extra_beds INTEGER DEFAULT 0, extra_bed_price INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`, (err) => { if (err) { console.log('Room recreate failed:', err.message); setupBookingHistoryTable(); return; } db.run(`INSERT INTO rooms (id, type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active, created_at) SELECT id, type, name, description, rooms_count, area_sqm, max_guests, COALESCE(furniture, '[]'), COALESCE(amenities, '[]'), COALESCE(floors, '[]'), COALESCE(price_per_night, 0) as price_per_night, image_path, COALESCE(extra_beds, 0), COALESCE(extra_bed_price, 0), is_active, created_at FROM rooms_backup`, (err) => { if (err) console.log('Room data restore failed:', err.message); db.run(`DROP TABLE rooms_backup`, (err) => {}); 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); }); }); const modules = {}; const authModule = require('./modules/auth'); const bookingsModule = require('./modules/bookings'); const adminBookingsModule = require('./modules/adminBookings'); const promocodesModule = require('./modules/promocodes'); const roomsModule = require('./modules/rooms'); const usersModule = require('./modules/users'); const settingsModule = require('./modules/settings'); const reviewsModule = require('./modules/reviews'); const translationsModule = require('./modules/translations'); const backupModule = require('./modules/backup'); const { runStartupTests } = require('./tests/runStartupTests'); modules.auth = authModule; modules.bookings = bookingsModule; modules.promocodes = promocodesModule; modules.rooms = roomsModule; modules.users = usersModule; modules.adminBookings = adminBookingsModule; modules.settings = settingsModule; modules.reviews = reviewsModule; modules.translations = translationsModule; modules.backup = backupModule; authModule.init(db, JWT_SECRET); bookingsModule.init(db); adminBookingsModule.init(db); promocodesModule.init(db); roomsModule.init(db); usersModule.init(db, bcrypt); settingsModule.init(db); reviewsModule.init(db, settingsModule); backupModule.init(db, dbPath); 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 stmt = db.prepare(`INSERT INTO rooms (type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`); config.DEFAULT_ROOMS.forEach(r => { stmt.run( r.type, r.name, r.description, r.rooms_count, r.area_sqm, r.max_guests, JSON.stringify(r.furniture || []), JSON.stringify(r.amenities || []), JSON.stringify(r.floors || []), r.price_per_night, r.image_path, r.extra_beds || 0, r.extra_bed_price || 0 ); }); stmt.finalize(); console.log('✅ Default rooms initialized'); }); } function syncAdmin() { if (!ADMIN_LOGIN || !ADMIN_PASSWORD) { console.warn('WARNING: ADMIN_LOGIN or ADMIN_PASSWORD not set, skipping admin sync'); return; } const hash = bcrypt.hashSync(ADMIN_PASSWORD, 10); db.get(`SELECT id, role FROM users WHERE login = ?`, [ADMIN_LOGIN], (err, row) => { if (err) { console.error('Admin sync error:', err); return; } if (row) { db.run(`UPDATE users SET password_hash = ?, role = 'admin' WHERE login = ?`, [hash, ADMIN_LOGIN], (err) => { if (err) console.error('Admin update error:', err); else console.log(`✅ Superadmin "${ADMIN_LOGIN}" updated from .env`); }); } else { db.run(`INSERT INTO users (login, password_hash, full_name, email, role) VALUES (?, ?, 'Администратор', NULL, 'admin')`, [ADMIN_LOGIN, hash], (err) => { if (err) console.error('Admin creation error:', err); else console.log(`✅ Superadmin "${ADMIN_LOGIN}" created from .env`); }); } }); } setTimeout(() => { initDefaultRooms(); syncAdmin(); }, 500); authModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); bookingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); adminBookingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); promocodesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); roomsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, upload); usersModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); settingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); reviewsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); backupModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); app.get('/api/translations/:lang', (req, res) => { const lang = req.params.lang; const translations = translationsModule.getTranslations(lang); if (!translations) { return res.status(404).json({ error: 'Language not found' }); } res.json(translations); }); app.get('/api/countries-cities', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'data', 'countries-cities.js')); }); app.get('/api/countries', (req, res) => { const countriesPath = path.join(__dirname, 'public', 'data', 'countries.js'); fs.readFile(countriesPath, 'utf8', (err, data) => { if (err) return res.status(500).json({ error: 'File not found' }); const match = data.match(/const\s+COUNTRIES\s*=\s*(\[.*\]);/s); if (match) { try { 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' }); } } else { res.status(500).json({ error: 'Parse error' }); } }); }); function safeJsonString(str) { return str.replace(/'/g, "'").replace(/\\"/g, '"'); } app.get('/api/cities/:countryCode', (req, res) => { const countryCode = req.params.countryCode; const citiesPath = path.join(__dirname, 'public', 'data', 'cities', `${countryCode}.js`); const majorCitiesPath = path.join(__dirname, 'public', 'data', 'cities', 'major.js'); const filePath = fs.existsSync(citiesPath) ? citiesPath : (fs.existsSync(majorCitiesPath) ? majorCitiesPath : null); if (!filePath) { return res.json({ cities: [], popular: [], countryCode }); } try { const data = fs.readFileSync(filePath, 'utf8'); let cities = []; const arrayMatch = data.match(/const\s+CITIES_\w+\s*=\s*(\[.*?\]);/s); if (arrayMatch) { try { cities = JSON.parse(safeJsonString(arrayMatch[1])); } catch (e) { const jsMatch = arrayMatch[1].replace(/'/g, '"'); try { cities = JSON.parse(jsMatch); } catch (e2) { console.error('Failed to parse cities:', e2); } } } res.json({ cities, popular: [], countryCode }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/uploads/rooms/:filename', (req, res) => { const filePath = path.join(roomsUploadsDir, req.params.filename); if (fs.existsSync(filePath)) { res.sendFile(filePath); } else { res.status(404).json({ error: 'File not found' }); } }); app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/admin', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin.html')); }); async function convertImages() { const imgDir = path.join(__dirname, 'public', 'img'); if (!fs.existsSync(imgDir)) { console.log('Папка img не найдена, пропускаем конвертацию.'); return; } const files = fs.readdirSync(imgDir); for (const file of files) { const ext = path.extname(file).toLowerCase(); if (ext === '.jpg' || ext === '.jpeg' || ext === '.png') { const name = path.parse(file).name; const webpPath = path.join(imgDir, `${name}.webp`); if (!fs.existsSync(webpPath)) { try { await sharp(path.join(imgDir, file)) .webp({ quality: 85 }) .toFile(webpPath); console.log(`✅ Сконвертировано: ${file} -> ${name}.webp`); } catch (err) { console.error(`❌ Ошибка при конвертации ${file}:`, err); } } } } } convertImages().then(() => { const server = app.listen(PORT, async () => { console.log('✅ HOTEL777KEY is set'); console.log('📊 Prometheus metrics available at: http://localhost:' + PORT + '/metrics'); 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')); });