418 lines
18 KiB
JavaScript
418 lines
18 KiB
JavaScript
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/<slug> → frontend/textbooks/<html_path>
|
|
const _textbookDb = require('./db/db');
|
|
const _stmtTextbookPath = _textbookDb.prepare('SELECT html_path FROM textbooks WHERE slug=? AND is_active=1');
|
|
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');
|
|
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'));
|