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 { ensureEnvSecret, initDatabase } = require('./modules/database'); ensureEnvSecret('HOTEL777KEY', 'HOTEL777KEY'); ensureEnvSecret('JWT_SECRET', 'JWT_SECRET'); 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 roomsUploadsDir = path.join(__dirname, 'data', 'room_images'); 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('/data', express.static(path.join(__dirname, 'data'))); 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 = 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); initDatabase(db); 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 seasonalPricesModule = require('./modules/seasonalPrices'); const emailModule = require('./modules/email'); const reportsModule = require('./modules/reports'); 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; modules.seasonalPrices = seasonalPricesModule; modules.email = emailModule; modules.reports = reportsModule; 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); seasonalPricesModule.init(db); emailModule.init(db, settingsModule); reportsModule.init(db); 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); seasonalPricesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); emailModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); reportsModule.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('/data/room_images/: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')); });