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'); 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'; if (!API_KEY) { console.error('FATAL: HOTEL777KEY environment variable not set'); process.exit(1); } app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); 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.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 )`, (err) => { if (err) console.error('Table creation error:', err); }); db.run(`ALTER TABLE bookings ADD COLUMN wishes TEXT`, (err) => { if (err && !err.message.includes('duplicate column name')) { console.error('Migration error:', err); } }); db.run(`ALTER TABLE bookings ADD COLUMN status TEXT DEFAULT 'новая'`, (err) => { if (err && !err.message.includes('duplicate column name')) { console.error('Migration error:', err); } }); 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 )`, (err) => { if (err) console.error('Users table creation error:', err); else syncAdmin(); }); 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`); }); } }); } function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); jwt.verify(token, JWT_SECRET, (err, user) => { if (err) return res.status(403).json({ error: 'Invalid or expired token' }); req.user = user; next(); }); } function requireAdmin(req, res, next) { if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); next(); } 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); } } } } } app.post('/api/bookings', (req, res) => { const { name, phone, adults, children, checkin, checkout, wishes } = req.body; if (!name || !phone || !adults || !checkin || !checkout) { return res.status(400).json({ error: 'Missing required fields' }); } const stmt = db.prepare(`INSERT INTO bookings (name, phone, adults, children, checkin_date, checkout_date, wishes, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'новая')`); stmt.run(name, phone, parseInt(adults), parseInt(children || 0), checkin, checkout, wishes || null, function(err) { if (err) { console.error(err); return res.status(500).json({ error: 'Database error' }); } res.status(201).json({ id: this.lastID, message: 'Booking saved' }); }); stmt.finalize(); }); app.get('/api/admin/bookings', authenticateToken, (req, res) => { db.all(`SELECT id, name, phone, adults, children, checkin_date, checkout_date, wishes, status, created_at FROM bookings ORDER BY checkin_date ASC`, [], (err, rows) => { if (err) { console.error(err); return res.status(500).json({ error: 'Database error' }); } res.json(rows); }); }); app.patch('/api/admin/bookings/:id', authenticateToken, requireAdmin, (req, res) => { const bookingId = parseInt(req.params.id); const { status } = req.body; const validStatuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена']; if (!status || !validStatuses.includes(status)) { return res.status(400).json({ error: 'Invalid status. Valid: ' + validStatuses.join(', ') }); } db.run(`UPDATE bookings SET status = ? WHERE id = ?`, [status, bookingId], (err) => { if (err) return res.status(500).json({ error: 'Database error' }); db.get(`SELECT id, name, phone, adults, children, checkin_date, checkout_date, wishes, status, created_at FROM bookings WHERE id = ?`, [bookingId], (err, row) => { if (err) return res.status(500).json({ error: 'Database error' }); res.json({ message: 'Status updated', booking: row }); }); }); }); app.get('/api/bookings', (req, res) => { const providedKey = req.headers['x-api-key']; if (!providedKey || providedKey !== API_KEY) { return res.status(401).json({ error: 'Invalid or missing API key' }); } db.all(`SELECT id, name, phone, adults, children, checkin_date, checkout_date, wishes, status, created_at FROM bookings ORDER BY checkin_date ASC`, (err, rows) => { if (err) { console.error(err); return res.status(500).json({ error: 'Database error' }); } res.json(rows); }); }); app.post('/api/auth/login', (req, res) => { 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) => { if (err) return res.status(500).json({ error: 'Database error' }); 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' }); const token = jwt.sign({ id: user.id, login: user.login, role: user.role }, JWT_SECRET, { expiresIn: '24h' }); res.json({ token, user: { id: user.id, login: user.login, full_name: user.full_name, email: user.email, role: user.role } }); }); }); app.put('/api/auth/me', authenticateToken, (req, res) => { const { full_name, email } = req.body; db.run(`UPDATE users SET full_name = COALESCE(?, full_name), email = COALESCE(?, email) WHERE id = ?`, [full_name || null, email || null, req.user.id], function(err) { if (err) return res.status(500).json({ error: 'Database error' }); db.get(`SELECT id, login, full_name, email, role FROM users WHERE id = ?`, [req.user.id], (err, row) => { if (err) return res.status(500).json({ error: 'Database error' }); res.json({ message: 'Profile updated', user: row }); }); }); }); app.post('/api/auth/change-password', authenticateToken, (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' }); 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' }); const match = bcrypt.compareSync(current_password, row.password_hash); if (!match) return res.status(401).json({ error: 'Current password is incorrect' }); const hash = bcrypt.hashSync(new_password, 10); db.run(`UPDATE users SET password_hash = ? WHERE id = ?`, [hash, req.user.id], (err) => { if (err) return res.status(500).json({ error: 'Database error' }); res.json({ message: 'Password changed successfully' }); }); }); }); app.get('/api/admin/users', authenticateToken, requireAdmin, (req, res) => { db.all(`SELECT id, login, full_name, email, role, created_at FROM users ORDER BY created_at DESC`, [], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); res.json(rows); }); }); app.post('/api/admin/users', authenticateToken, requireAdmin, (req, res) => { const { login, password, full_name, email, role } = req.body; if (!login || !password) return res.status(400).json({ error: 'Login and password required' }); if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Role must be "admin" or "user"' }); if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' }); const hash = bcrypt.hashSync(password, 10); db.run(`INSERT INTO users (login, password_hash, full_name, email, role) VALUES (?, ?, ?, ?, ?)`, [login, hash, full_name || null, email || null, role], function(err) { if (err) { if (err.message.includes('UNIQUE constraint')) return res.status(409).json({ error: 'Login already exists' }); return res.status(500).json({ error: 'Database error' }); } db.get(`SELECT id, login, full_name, email, role, created_at FROM users WHERE id = ?`, [this.lastID], (err, row) => { res.status(201).json({ message: 'User created', user: row }); }); }); }); app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => { const userId = parseInt(req.params.id); const { full_name, email, role, password } = req.body; if (role && !['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Role must be "admin" or "user"' }); db.get(`SELECT id FROM users WHERE id = ?`, [userId], (err, row) => { if (err) return res.status(500).json({ error: 'Database error' }); if (!row) return res.status(404).json({ error: 'User not found' }); let fields = []; let values = []; if (full_name !== undefined) { fields.push('full_name = ?'); values.push(full_name || null); } if (email !== undefined) { fields.push('email = ?'); values.push(email || null); } if (role !== undefined) { fields.push('role = ?'); values.push(role); } if (password) { if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' }); fields.push('password_hash = ?'); values.push(bcrypt.hashSync(password, 10)); } if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(userId); db.run(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`, values, (err) => { if (err) return res.status(500).json({ error: 'Database error' }); db.get(`SELECT id, login, full_name, email, role, created_at FROM users WHERE id = ?`, [userId], (err, row) => { res.json({ message: 'User updated', user: row }); }); }); }); }); app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => { const userId = parseInt(req.params.id); if (userId === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' }); db.get(`SELECT login FROM users WHERE id = ?`, [userId], (err, row) => { if (err) return res.status(500).json({ error: 'Database error' }); if (!row) return res.status(404).json({ error: 'User not found' }); if (row.login === ADMIN_LOGIN) return res.status(403).json({ error: 'Cannot delete superadmin defined in .env' }); db.run(`DELETE FROM users WHERE id = ?`, [userId], (err) => { if (err) return res.status(500).json({ error: 'Database error' }); res.json({ message: 'User deleted' }); }); }); }); 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')); }); convertImages().then(() => { app.listen(PORT, () => { console.log('✅ HOTEL777KEY is', API_KEY); console.log(`✅ Hotel 777 server running on http://localhost:${PORT}`); }); });