const config = require('./config'); // validates .env, fails fast on error const logger = require('./utils/logger'); // structured logging /* ── Schema check — fail fast if migrate hasn't been run ── */ const _bootDb = require('./db/db'); try { _bootDb.prepare('SELECT 1 FROM users LIMIT 1').get(); _bootDb.prepare('SELECT 1 FROM role_permissions LIMIT 1').get(); } catch (e) { process.stderr.write( '[boot] FATAL: schema not initialized.\n' + 'Run: npm run migrate && npm run seed:permissions\n' ); process.exit(1); } const express = require('express'); const cors = require('cors'); const path = require('path'); const compression = require('compression'); const authRoutes = require('./routes/auth'); const subjectRoutes = require('./routes/subjects'); const sessionRoutes = require('./routes/sessions'); const adminRoutes = require('./routes/admin'); const questionRoutes = require('./routes/questions'); const classRoutes = require('./routes/classes'); const assignmentRoutes = require('./routes/assignments'); const fileRoutes = require('./routes/files'); const testRoutes = require('./routes/tests'); const notificationRoutes = require('./routes/notifications'); const permissionRoutes = require('./routes/permissions'); const submissionRoutes = require('./routes/submissions'); const courseRoutes = require('./routes/courses'); const lessonRoutes = require('./routes/lessons'); const gamificationRoutes = require('./routes/gamification'); const shopRoutes = require('./routes/shop'); const templateRoutes = require('./routes/templates'); const bookmarkRoutes = require('./routes/bookmarks'); const searchRoutes = require('./routes/search'); const flashcardRoutes = require('./routes/flashcards'); const settingsRoutes = require('./routes/settings'); const analyticsRoutes = require('./routes/analytics'); const liveRoutes = require('./routes/live'); const classroomRoutes = require('./routes/classroom'); const guestClassroomRoutes = require('./routes/guestClassroom'); const gamesRoutes = require('./routes/games'); const knowledgeMapRoutes = require('./routes/knowledgeMap'); const petRoutes = require('./routes/pet'); const collectionRoutes = require('./routes/collection'); const redBookRoutes = require('./routes/red-book'); const parentRoutes = require('./routes/parent'); const exam9Routes = require('./routes/exam9'); const examPrepRoutes = require('./routes/exam-prep'); const textbookRoutes = require('./routes/textbooks'); const teacherStudentsRoutes = require('./routes/teacherStudents'); const { requestId, errorHandler } = require('./middleware/errorHandler'); const app = express(); const PORT = config.PORT; const isProd = config.isProd; /* ── Gzip compression (skip SSE streams — compression buffers chunks and breaks real-time) ── */ app.use(compression({ filter: (req, res) => { if (req.path.includes('/stream') || req.headers.accept === 'text/event-stream') return false; return compression.filter(req, res); }, })); /* ── Request ID — attach before everything else ── */ app.use(requestId); /* ── Security headers (no helmet dep needed) ── */ app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('X-XSS-Protection', '1; mode=block'); // classroom.html needs microphone + camera for WebRTC const isClassroom = _req.path === '/classroom' || _req.path === '/classroom.html'; res.setHeader('Permissions-Policy', isClassroom ? 'camera=(self), microphone=(self), geolocation=()' : 'camera=(), microphone=(), geolocation=()' ); res.setHeader('Content-Security-Policy', "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " + "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " + "img-src 'self' data: blob: https:; " + "connect-src 'self' https://cdn.jsdelivr.net https://stun.l.google.com; " + "frame-src 'self' https://www.youtube.com https://rutube.ru https://player.vimeo.com; " + "frame-ancestors 'self'" + (isProd ? "; upgrade-insecure-requests" : "") ); if (isProd) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); next(); }); /* ── Request logger ── */ app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const ms = Date.now() - start; const status = res.statusCode; const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info'; logger[level](`${req.method} ${req.path}`, { status, ms, requestId: req.requestId, userId: req.user?.id, }); }); next(); }); /* ── CORS ── */ app.set('trust proxy', 1); const allowedOrigins = [process.env.CLIENT_ORIGIN].filter(Boolean); app.use(cors({ origin: (origin, cb) => { if (!origin) return cb(null, !isProd); // dev: allow, prod: block cb(null, allowedOrigins.includes(origin) ? origin : false); }, credentials: true })); /* ── Body parser with size limit ── */ app.use(express.json({ limit: '1mb' })); /* ── Global API rate limit ── */ const rateLimit = require('./middleware/rateLimit'); const { requireFeature } = require('./middleware/features'); // Classroom real-time endpoints (cursor, stroke-preview) fire ~10/s per user — higher limit app.use('/api/classroom', rateLimit({ windowMs: 60_000, max: 6000, message: 'Слишком много запросов' })); app.use('/api', rateLimit({ windowMs: 60_000, max: 600, message: 'Слишком много запросов, подождите минуту' })); /* ── Routes ── */ app.use('/api/auth', authRoutes); app.use('/api/subjects', subjectRoutes); app.use('/api/sessions', sessionRoutes); app.use('/api/admin', adminRoutes); app.use('/api/questions', questionRoutes); app.use('/api/classes', classRoutes); app.use('/api/assignments', assignmentRoutes); app.use('/api/files', fileRoutes); app.use('/api/tests', testRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/permissions', permissionRoutes); app.use('/api/submissions', submissionRoutes); app.use('/api/courses', courseRoutes); app.use('/api/lessons', lessonRoutes); app.use('/api/gamification', gamificationRoutes); app.use('/api/shop', shopRoutes); app.use('/api/templates', templateRoutes); app.use('/api/bookmarks', bookmarkRoutes); app.use('/api/search', searchRoutes); app.use('/api/flashcards', requireFeature('flashcards'), flashcardRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/preferences', require('./routes/preferences')); app.use('/api/avatar', require('./routes/avatar')); app.use('/api/analytics', analyticsRoutes); app.use('/api/live', liveRoutes); app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom app.use('/api/classroom', classroomRoutes); app.use('/api/games', gamesRoutes); app.use('/api/knowledge-map', requireFeature('knowledge_map'), knowledgeMapRoutes); app.use('/api/pet', requireFeature('pet'), petRoutes); app.use('/api/collection', requireFeature('collection'), collectionRoutes); app.use('/api/red-book', requireFeature('red_book'), redBookRoutes); app.use('/api/biochem', requireFeature('biochem'), require('./routes/biochem')); app.use('/api/parent', parentRoutes); app.use('/api/exam9', exam9Routes); app.use('/api/exam-prep', examPrepRoutes); app.use('/api/textbooks', textbookRoutes); app.use('/api/teacher-students', teacherStudentsRoutes); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ const _featDb = require('./db/db'); const _stmtGlobalFeats = _featDb.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'"); const _stmtClassFeats = _featDb.prepare( 'SELECT c.features FROM classes c JOIN class_members cm ON cm.class_id = c.id WHERE cm.user_id = ?' ); const _stmtFreeStudentFeats = _featDb.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'"); const _jwtLib = require('jsonwebtoken'); app.get('/api/features', (req, res) => { const rows = _stmtGlobalFeats.all(); const features = {}; for (const r of rows) { const name = r.key.replace('feature_', '').replace('_enabled', ''); features[name] = r.value === '1'; } // Prevent browser caching — class features change when teacher updates settings res.setHeader('Cache-Control', 'no-store'); // For authenticated students: overlay class-specific feature flags const token = (req.headers.authorization || '').replace('Bearer ', ''); if (token) { try { const payload = _jwtLib.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); if (payload.role === 'student' || payload.role === 'free_student') { const classes = _stmtClassFeats.all(payload.id); if (classes.length === 0 && payload.role === 'student') { features._no_class = true; // regular student has no class — restrict access } for (const cls of classes) { if (!cls.features) continue; try { const f = JSON.parse(cls.features); for (const [key, val] of Object.entries(f)) { if (val === false) features[key] = false; } } catch (e) { console.error('[features] class JSON parse error:', e.message); } } // Apply role-level free_student restrictions (set by admin) if (payload.role === 'free_student') { const fsRow = _stmtFreeStudentFeats.get(); if (fsRow?.value) { try { const fsFeats = JSON.parse(fsRow.value); for (const [key, val] of Object.entries(fsFeats)) { if (val === false) features[key] = false; } } catch (e) { console.error('[features] free_student JSON parse error:', e.message); } } } } } catch { /* invalid/expired token — anonymous features */ } } res.json(features); }); /* ── Health check ── */ const _startTime = Date.now(); const _pkg = require('../package.json'); const _db = require('./db/db'); /* GET /api/ice-servers — WebRTC ICE config (STUN + optional TURN from env) Required for clients behind symmetric NAT/CGNAT (school/corporate networks). */ app.get('/api/ice-servers', (req, res) => { const iceServers = [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ]; if (config.TURN_URL && config.TURN_USER && config.TURN_PASS) { iceServers.push({ urls: config.TURN_URL, username: config.TURN_USER, credential: config.TURN_PASS, }); } res.json({ iceServers }); }); app.get('/api/health', (req, res) => { let dbStatus = 'ok'; let dbLatencyMs = null; try { const t0 = Date.now(); _db.prepare('SELECT 1').get(); dbLatencyMs = Date.now() - t0; } catch { dbStatus = 'error'; } const status = dbStatus === 'ok' ? 'ok' : 'degraded'; const httpStatus = status === 'ok' ? 200 : 503; // Minimal public response — no version/runtime details const pub = { status, timestamp: new Date().toISOString() }; // Detailed response only for authenticated admins const token = (req.headers.authorization || '').replace('Bearer ', ''); if (token) { try { const jwt = require('jsonwebtoken'); const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); const u = _db.prepare('SELECT role FROM users WHERE id = ?').get(payload.id); if (u?.role === 'admin') { const uptimeSec = Math.floor((Date.now() - _startTime) / 1000); return res.status(httpStatus).json({ ...pub, version: _pkg.version, uptime: { seconds: uptimeSec, human: _fmtUptime(uptimeSec) }, db: { status: dbStatus, latency_ms: dbLatencyMs }, node: process.version, env: process.env.NODE_ENV || 'development', }); } } catch { /* invalid token → fall through to public response */ } } res.status(httpStatus).json(pub); }); function _fmtUptime(s) { const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m ${sec}s`; if (m > 0) return `${m}m ${sec}s`; return `${sec}s`; } /* ── Static frontend ── */ const frontendDir = path.join(__dirname, '../../frontend'); const jsDir = path.join(__dirname, '../../js'); const staticCache = isProd ? { maxAge: '7d' } : { setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') }; app.use('/js', express.static(jsDir, staticCache)); app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache)); app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache)); app.use('/avatars', express.static(path.join(__dirname, '../uploads/avatars'), { maxAge: '1d' })); // Redirect legacy .html URLs → clean URLs (301) app.use((req, res, next) => { if (req.path.endsWith('.html') && !req.path.startsWith('/api/')) { const clean = req.path.slice(0, -5); const qs = req.url.slice(req.path.length); // preserve ?query return res.redirect(301, clean + qs); } next(); }); // Clean URL for textbooks: /textbook/ → frontend/textbooks/ // With ?embed=1 — inject CSS that hides headers/sidebars + JS-bridge for classroom sync. const fs = require('fs'); const _textbookDb = require('./db/db'); const _stmtTextbookPath = _textbookDb.prepare('SELECT html_path FROM textbooks WHERE slug=? AND is_active=1'); const _embedCache = new Map(); // html_path → {mtime, html} const EMBED_INJECT = ` `; function _renderEmbed(filePath, slug) { let stat; try { stat = fs.statSync(filePath); } catch { return null; } const cached = _embedCache.get(filePath); if (cached && cached.mtime === stat.mtimeMs && cached.slug === slug) return cached.html; let html; try { html = fs.readFileSync(filePath, 'utf8'); } catch { return null; } const inject = EMBED_INJECT.replace('__LS_SLUG__', JSON.stringify(slug)); if (html.includes('')) html = html.replace('', inject + ''); else html = inject + html; _embedCache.set(filePath, { mtime: stat.mtimeMs, slug, html }); return html; } app.get('/textbook/:slug', (req, res, next) => { const row = _stmtTextbookPath.get(req.params.slug); if (!row) return next(); const filePath = path.join(frontendDir, 'textbooks', row.html_path); if (!isProd) res.setHeader('Cache-Control', 'no-store'); if (req.query.embed === '1') { const html = _renderEmbed(filePath, req.params.slug); if (html == null) return next(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); return res.send(html); } res.sendFile(filePath, err => { if (err) next(); }); }); // Catalog: /textbooks → frontend/textbooks.html (explicit to avoid conflict with /textbooks/ directory) app.get('/textbooks', (_req, res) => { if (!isProd) res.setHeader('Cache-Control', 'no-store'); res.sendFile(path.join(frontendDir, 'textbooks.html')); }); // ── Exam preparation module: /exam-prep/:examKey/{,variants,practice,topics,mock}[/...] // All sub-pages share the same examKey segment; sub-view is the second segment. // The HTML file is chosen by the sub-view; examKey is parsed client-side from URL. function sendExamPrep(res, file) { if (!isProd) res.setHeader('Cache-Control', 'no-store'); res.sendFile(path.join(frontendDir, file)); } const EXAM_PREP_VIEWS = { variants: 'exam-prep-variants.html', practice: 'exam-prep-practice.html', topics: 'exam-prep-topics.html', mock: 'exam-prep-mock.html', }; // /exam-prep → redirect to default track (math9 for now) app.get('/exam-prep', (_req, res) => res.redirect(302, '/exam-prep/math9')); // /exam-prep/:examKey → dashboard app.get('/exam-prep/:examKey', (_req, res) => sendExamPrep(res, 'exam-prep.html')); // /exam-prep/:examKey/:view → corresponding sub-page (sub-view + optional trailing segments) app.get(['/exam-prep/:examKey/:view', '/exam-prep/:examKey/:view/*'], (req, res, next) => { const file = EXAM_PREP_VIEWS[req.params.view]; if (!file) return next(); sendExamPrep(res, file); }); // Legacy /exam9 → new variants browser (F2 port complete) app.get('/exam9', (_req, res) => res.redirect(301, '/exam-prep/math9/variants')); // Serve HTML files without extension (/dashboard → dashboard.html) // In dev: disable cache so edits are always picked up immediately const htmlCacheOpts = isProd ? { extensions: ['html'] } : { extensions: ['html'], setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-store'); }, }; app.use(express.static(frontendDir, htmlCacheOpts)); app.get('/', (_req, res) => res.sendFile(path.join(frontendDir, 'login.html'))); app.get('/login', (_req, res) => res.sendFile(path.join(frontendDir, 'login.html'))); /* ── Custom error pages ── */ app.get('/403', (_req, res) => res.status(403).sendFile(path.join(frontendDir, '403.html'))); app.get('/404', (_req, res) => res.status(404).sendFile(path.join(frontendDir, '404.html'))); app.get('/500', (_req, res) => res.status(500).sendFile(path.join(frontendDir, '500.html'))); /* ── Global error handler ── */ app.use(errorHandler); app.use((_req, res) => res.status(404).sendFile(path.join(frontendDir, '404.html'))); const server = app.listen(PORT, () => logger.info(`Server running on port ${PORT}`, { env: config.NODE_ENV })); /* ── WebSocket server for low-latency classroom events (cursor + preview) ── */ require('./ws-server').attach(server); /* ── Graceful shutdown ── */ function shutdown(signal) { logger.info(`${signal} received — shutting down gracefully`); // Close WebSocket connections first so HTTP server can stop accepting new requests cleanly try { require('./ws-server').closeAll(); } catch (e) { logger.error('ws close error', { err: e.message }); } server.close(() => { try { const _shutDb = require('./db/db'); _shutDb.exec('PRAGMA optimize'); _shutDb.close(); } catch (e) { logger.error('db close error', { err: e.message }); } logger.info('Server closed'); process.exit(0); }); // Force exit after 5s if connections hang setTimeout(() => process.exit(1), 5000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));