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'); require('dotenv').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 MONITORING_USER = process.env.MONITORING_USER || 'monitoring'; const MONITORING_PASSWORD = process.env.MONITORING_PASSWORD || 'monitoring123'; 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] }); if (!API_KEY) { console.error('FATAL: HOTEL777KEY environment variable not set'); process.exit(1); } app.use(express.json()); 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((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 )`); 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); } 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'); }); } }); }); }); 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 { 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; 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); 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)); 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); usersModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); settingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); reviewsModule.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 { const countries = eval(match[1]); res.json(countries); } catch (e) { res.status(500).json({ error: 'Parse error' }); } } else { res.status(500).json({ error: 'Parse error' }); } }); }); 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) { 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 { const evalResult = eval(defaultMatch[1]); if (Array.isArray(evalResult)) { cities = evalResult; } } catch (e) {} } } res.json({ cities, popular: [], countryCode }); } catch (err) { res.status(500).json({ error: err.message }); } }); 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(() => { app.listen(PORT, async () => { console.log('✅ HOTEL777KEY is', API_KEY); console.log('📊 Prometheus metrics available at: http://localhost:' + PORT + '/metrics'); console.log('🔐 Monitoring credentials: ' + MONITORING_USER + ' / ' + (MONITORING_PASSWORD ? '***' : 'NOT SET')); console.log(''); await runStartupTests(db, modules); console.log(`✅ Hotel 777 server running on http://localhost:${PORT}`); }); });