LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const ctrl = require('../controllers/adminController');
router.use(authMiddleware);
/* Features — teachers may read (need to know what's enabled for their classes) */
router.get('/features', requireRole('admin', 'teacher'), ctrl.getFeatures);
router.patch('/features', requireRole('admin'), ctrl.updateFeatures);
router.get('/free-student-features', requireRole('admin', 'teacher'), ctrl.getFreeStudentFeatures);
router.patch('/free-student-features', requireRole('admin'), ctrl.updateFreeStudentFeatures);
/* Everything below is admin-only */
router.use(requireRole('admin'));
router.get('/stats', ctrl.getStats);
router.get('/users', ctrl.getUsers);
router.patch('/users/:id/role', ctrl.updateRole);
router.get('/users/:id/sessions', ctrl.getUserSessions);
router.delete('/users/:id/sessions', ctrl.clearUserSessions);
router.post('/users/:id/sessions/clear', ctrl.clearUserSessions);
router.patch('/users/:id', ctrl.updateUser);
router.patch('/users/:id/ban', ctrl.banUser);
router.delete('/users/:id', ctrl.deleteUser);
router.get('/sessions', ctrl.getAllSessions);
router.get('/sessions/:id', ctrl.getSessionDetail);
/* Audit log */
router.get('/audit-log', ctrl.getAuditLog);
router.delete('/audit-log', ctrl.clearAuditLog);
/* Error log */
router.get('/error-log', ctrl.getErrorLog);
router.delete('/error-log', ctrl.clearErrorLog);
/* System health */
router.get('/health', ctrl.getHealth);
/* Topics CRUD */
router.get('/topics', ctrl.getTopics);
router.post('/topics', ctrl.createTopic);
router.patch('/topics/:id', ctrl.updateTopic);
router.delete('/topics/:id', ctrl.deleteTopic);
/* Broadcast notifications */
router.post('/broadcast', ctrl.broadcast);
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { teacherOverview } = require('../controllers/analyticsController');
router.get('/teacher', authMiddleware, requireRole('teacher', 'admin'), teacherOverview);
module.exports = router;
+56
View File
@@ -0,0 +1,56 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const validate = require('../middleware/validate');
const ctrl = require('../controllers/assignmentController');
const MODES = ['exam', 'practice', 'repeat', 'ct'];
const createSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
subject_slug: { type: 'string', maxLen: 100 },
mode: { type: 'string', oneOf: MODES },
count: { type: 'number', min: 1, max: 200 },
deadline: { type: 'string', maxLen: 30 },
}};
const updateSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
subject_slug: { type: 'string', maxLen: 100 },
mode: { type: 'string', oneOf: MODES },
count: { type: 'number', min: 1, max: 200 },
deadline: { type: 'string', maxLen: 30 },
}};
const directSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
subject_slug: { type: 'string', maxLen: 100 },
mode: { type: 'string', oneOf: MODES },
count: { type: 'number', min: 1, max: 200 },
deadline: { type: 'string', maxLen: 30 },
student_email: { type: 'string', maxLen: 255 },
}};
const bulkSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
class_id: { type: 'number', required: true, min: 1 },
mode: { type: 'string', oneOf: MODES },
count: { type: 'number', min: 1, max: 200 },
}};
router.use(authMiddleware);
router.get('/my', ctrl.myAssignments);
router.get('/teacher', requireRole('teacher','admin'), ctrl.teacherAssignments);
router.get('/templates', requireRole('teacher','admin'), ctrl.listTemplates);
router.post('/templates', requireRole('teacher','admin'), ctrl.saveTemplate);
router.delete('/templates/:id', requireRole('teacher','admin'), ctrl.deleteTemplate);
router.post('/bulk', requireRole('teacher','admin'), validate(bulkSchema), ctrl.bulkCreateAssignment);
router.post('/', requireRole('teacher','admin'), validate(directSchema), ctrl.createDirectAssignment);
router.post('/:id/start', ctrl.startAssignment);
router.get('/:id/results', requireRole('teacher','admin'), ctrl.assignmentResults);
router.get('/:id/question-stats', requireRole('teacher','admin'), ctrl.assignmentQuestionStats);
router.get('/:id/sessions/:session_id/review', requireRole('teacher','admin'), ctrl.assignmentSessionReview);
router.put('/:id', requireRole('teacher','admin'), validate(updateSchema), ctrl.updateAssignment);
router.delete('/:id', requireRole('teacher','admin'), ctrl.deleteAssignment);
module.exports = router;
+32
View File
@@ -0,0 +1,32 @@
const router = require('express').Router();
const { register, login, me, updateProfile } = require('../controllers/authController');
const { authMiddleware } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const validate = require('../middleware/validate');
const loginLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много попыток входа, подождите минуту' });
const registerLimiter = rateLimit({ windowMs: 60_000, max: 5, message: 'Слишком много регистраций, подождите минуту' });
const profileLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много запросов, подождите минуту' });
const registerSchema = { body: {
email: { type: 'string', required: true, maxLen: 255, match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
password: { type: 'string', required: true, minLen: 6, maxLen: 128 },
name: { type: 'string', required: true, minLen: 1, maxLen: 100 },
}};
const loginSchema = { body: {
email: { type: 'string', required: true, maxLen: 255 },
password: { type: 'string', required: true, minLen: 1, maxLen: 128 },
}};
const profileSchema = { body: {
name: { type: 'string', minLen: 1, maxLen: 100 },
newPassword: { type: 'string', minLen: 6, maxLen: 128 },
currentPassword: { type: 'string', maxLen: 128 },
}};
router.post('/register', registerLimiter, validate(registerSchema), register);
router.post('/login', loginLimiter, validate(loginSchema), login);
router.get('/me', authMiddleware, me);
router.get('/profile', authMiddleware, me);
router.patch('/profile', authMiddleware, profileLimiter, validate(profileSchema), updateProfile);
module.exports = router;
+18
View File
@@ -0,0 +1,18 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/biochemController');
router.use(authMiddleware);
router.get('/elements', c.getElements);
router.get('/molecules', c.getMolecules);
router.get('/molecules/:id', c.getMolecule);
router.post('/validate', c.validate);
router.get('/reactions', c.getReactions);
router.get('/challenges', c.getChallenges);
router.post('/challenges/:id/solve', c.solveChallenge);
router.get('/saved', c.getSaved);
router.post('/saved', c.saveMolecule);
router.delete('/saved/:id', c.deleteSaved);
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const ctrl = require('../controllers/bookmarkController');
router.use(authMiddleware);
router.get('/', ctrl.list);
router.post('/', ctrl.add);
router.delete('/:id', ctrl.remove);
router.delete('/entity/:type/:entityId', ctrl.removeByEntity);
router.get('/check/:type/:entityId', ctrl.check);
module.exports = router;
+48
View File
@@ -0,0 +1,48 @@
const router = require('express').Router();
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const validate = require('../middleware/validate');
const ctrl = require('../controllers/classController');
const assignCtrl = require('../controllers/assignmentController');
const joinLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много попыток, подождите минуту' });
const createLimiter = rateLimit({ windowMs: 60_000, max: 5, message: 'Слишком много запросов, подождите минуту' });
const joinSchema = { body: { invite_code: { type: 'string', required: true, minLen: 1, maxLen: 20 } } };
const createSchema = { body: { name: { type: 'string', required: true, minLen: 1, maxLen: 200 } } };
const updateSchema = { body: {
name: { type: 'string', minLen: 1, maxLen: 200 },
description: { type: 'string', maxLen: 1000 },
}};
const assignmentSchema = { body: {
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
subject_slug: { type: 'string', maxLen: 100 },
mode: { type: 'string', oneOf: ['exam', 'practice', 'repeat', 'ct'] },
count: { type: 'number', min: 1, max: 200 },
deadline: { type: 'string', maxLen: 30 },
}};
router.use(authMiddleware);
/* ── Student (must be before /:id to avoid route conflicts) ── */
router.post('/join', requireRole('student','free_student'), joinLimiter, validate(joinSchema), ctrl.joinClass);
router.get('/student/my', ctrl.myClasses);
router.get('/students', requireRole('teacher','admin'), ctrl.listStudents);
/* ── Teacher / Admin ── */
router.get('/', requireRole('teacher','admin'), ctrl.listClasses);
router.post('/', requireRole('teacher','admin'), requirePermission('classes.manage'), createLimiter, validate(createSchema), ctrl.createClass);
router.get('/:id', requireRole('teacher','admin'), ctrl.getClass);
router.patch('/:id', requireRole('teacher','admin'), requirePermission('classes.manage'), validate(updateSchema), ctrl.updateClass);
router.delete('/:id', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.deleteClass);
router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode);
router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal);
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
router.get('/:id/announcements', requireRole('teacher','admin'), ctrl.getAnnouncements);
router.post('/:id/announcements', requireRole('teacher','admin'), requirePermission('announcements.send'), ctrl.createAnnouncement);
router.delete('/:id/announcements/:aid', requireRole('teacher','admin'), requirePermission('announcements.send'), ctrl.deleteAnnouncement);
router.get('/:id/feed', ctrl.classFeed);
module.exports = router;
+104
View File
@@ -0,0 +1,104 @@
const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const { authMiddleware, requireRole } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const c = require('../controllers/classroomController');
/* ── multer for chat image attachments ─────────────────────────────────── */
const _chatUploadsDir = path.join(__dirname, '../../uploads/chat');
const _chatStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, _chatUploadsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, '');
cb(null, crypto.randomBytes(14).toString('hex') + ext);
},
});
const chatUpload = multer({
storage: _chatStorage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const ok = ['image/jpeg','image/png','image/gif','image/webp'].includes(file.mimetype);
cb(null, ok);
},
});
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
const auth = [authMiddleware];
const chatLimiter = rateLimit({ windowMs: 5_000, max: 5, message: 'Слишком много сообщений, подождите' });
// Template library — MUST be before /:id to avoid shadowing
router.get('/templates', ...teacher, c.getTemplates);
router.delete('/templates/:tid', ...teacher, c.deleteTemplate);
// Session lifecycle
router.post('/', ...teacher, c.createSession);
router.get('/online-students', ...teacher, c.getOnlineStudents);
router.get('/my/session', ...auth, c.getMySession);
router.get('/class/:classId/active', ...auth, c.getActiveSession);
router.get('/my/active', ...auth, c.getMyActive);
router.get('/:id', ...auth, c.getSession);
router.delete('/:id', ...teacher, c.endSession);
// Attendance
router.post('/:id/join', ...auth, c.joinSession);
router.post('/:id/leave', ...auth, c.leaveSession);
router.get('/:id/participants', ...auth, c.getParticipants);
router.get('/:id/attendance', ...teacher, c.getAttendance);
// Chat
router.post('/:id/chat', ...auth, chatLimiter, c.sendChat);
router.get('/:id/chat', ...auth, c.getChat);
router.post('/:id/chat/upload', ...auth, chatUpload.single('file'), c.uploadChatAttachment);
router.post('/:id/chat/:msgId/react', ...auth, c.reactToMessage);
// WebRTC signaling
router.post('/:id/signal', ...auth, c.signal);
// Whiteboard strokes
router.post('/:id/strokes', ...auth, c.postStrokes);
router.get('/:id/strokes', ...auth, c.getStrokes);
router.delete('/:id/strokes/:strokeId', ...teacher, c.deleteStroke);
router.patch('/:id/strokes/:strokeId', ...auth, c.updateStroke);
router.post('/:id/stroke-preview', ...auth, c.previewStroke);
// Multi-page
router.post('/:id/pages', ...teacher, c.addPage);
router.put('/:id/page', ...teacher, c.changePage);
router.patch('/:id/page-template', ...teacher, c.updatePageTemplate);
// Hand raise
router.post('/:id/hand', ...auth, c.raiseHand);
router.delete('/:id/hand', ...auth, c.lowerHand);
router.get('/:id/hands', ...auth, c.getHands);
// Whiteboard: clear page
router.post('/:id/clear-page', ...teacher, c.clearPage);
// WebRTC: mute peer, screen share broadcast
router.post('/:id/mute', ...teacher, c.mutePeer);
router.post('/:id/screen', ...teacher, c.screenStart);
router.delete('/:id/screen', ...teacher, c.screenStop);
// Cursor broadcast (all participants)
router.post('/:id/cursor', ...auth, c.broadcastCursor);
// Message pin (teacher only)
router.post('/:id/chat/:msgId/pin', ...teacher, c.pinMessage);
// Collaborative drawing permissions
router.post('/:id/allow-draw/:userId', ...teacher, c.allowDraw);
router.delete('/:id/allow-draw/:userId', ...teacher, c.revokeDraw);
// Session notes (per user)
router.get('/:id/notes', ...auth, c.getNotes);
router.put('/:id/notes', ...auth, c.saveNotes);
// Save current session as template
router.post('/:id/save-template', ...teacher, c.saveTemplate);
// Load template into current session
router.post('/:id/load-template', ...teacher, c.loadTemplate);
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/collectionController');
router.get('/', authMiddleware, c.getCollection);
module.exports = router;
+34
View File
@@ -0,0 +1,34 @@
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const c = require('../controllers/courseController');
router.use(authMiddleware);
// Course listing & special
router.get('/', c.list);
router.get('/search', c.search);
router.get('/continue', c.continueLesson);
router.get('/:id', c.get);
router.get('/:id/stats', requireRole('teacher','admin'), c.stats);
router.get('/:id/analytics', requireRole('teacher','admin'), c.analytics);
// Course mutations
router.post('/', requireRole('teacher','admin'), requirePermission('courses.manage'), c.create);
router.post('/:id/duplicate', requireRole('teacher','admin'), requirePermission('courses.manage'), c.duplicate);
router.patch('/:id/publish-all', requireRole('teacher','admin'), requirePermission('courses.manage'), c.publishAll);
router.put('/:id', requireRole('teacher','admin'), requirePermission('courses.manage'), c.update);
router.delete('/:id', requireRole('teacher','admin'), requirePermission('courses.manage'), c.remove);
// Sections
router.get('/:id/sections', requireRole('teacher','admin'), c.listSections);
router.post('/:id/sections', requireRole('teacher','admin'), c.createSection);
router.put('/:id/sections/:sid', requireRole('teacher','admin'), c.updateSection);
router.delete('/:id/sections/:sid', requireRole('teacher','admin'), c.deleteSection);
// Class courses
router.get('/class/:classId', c.listClassCourses);
router.post('/class/:classId/assign', requireRole('teacher','admin'), c.assignCourseToClass);
router.delete('/class/:classId/:courseId', requireRole('teacher','admin'), c.unassignCourseFromClass);
module.exports = router;
+73
View File
@@ -0,0 +1,73 @@
const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('crypto').randomUUID ? { v4: () => require('crypto').randomBytes(16).toString('hex') } : {};
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const ctrl = require('../controllers/fileController');
const { fixUtf8Name } = require('../utils/fixUtf8');
/* ── multer config ─────────────────────────────────────────────────────── */
const UPLOADS_DIR = path.join(__dirname, '../../uploads');
const ALLOWED = ['application/pdf','image/png','image/jpeg','image/gif','image/webp',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'];
const storage = multer.diskStorage({
destination: UPLOADS_DIR,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
const name = require('crypto').randomBytes(16).toString('hex') + ext;
cb(null, name);
},
});
const SAFE_EXTS = new Set(['.pdf','.png','.jpg','.jpeg','.gif','.webp','.doc','.docx','.ppt','.pptx','.xls','.xlsx','.txt']);
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
fileFilter: (_req, file, cb) => {
if (!ALLOWED.includes(file.mimetype)) return cb(null, false);
// Reject double extensions (.php.jpg, .exe.pdf, etc.)
const name = file.originalname;
const parts = name.split('.');
if (parts.length > 2) {
const inner = '.' + parts[parts.length - 2].toLowerCase();
if (['.php','.exe','.sh','.bat','.cmd','.ps1','.js','.html','.htm'].includes(inner)) return cb(null, false);
}
// Verify file extension is allowed
const ext = path.extname(name).toLowerCase();
if (ext && !SAFE_EXTS.has(ext)) return cb(null, false);
cb(null, true);
},
});
/* ── routes ─────────────────────────────────────────────────────────────── */
router.use(authMiddleware);
router.get('/', ctrl.listFiles);
router.post('/', requireRole('teacher','admin'), requirePermission('library.upload'), upload.single('file'), fixUtf8Name, ctrl.uploadFile);
/* ── folder routes (must be before /:id to avoid conflicts) ── */
router.get('/folders', ctrl.listFolders);
router.post('/folders', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.createFolder);
router.put('/folders/:id',requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.renameFolder);
router.delete('/folders/:id', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.deleteFolder);
router.get('/folders/:id/access', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.getFolderAccess);
router.delete('/folders/:id/access', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.clearFolderAccess);
router.post('/folders/:id/assign', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.assignFolder);
router.delete('/folders/:id/assign/:type/:targetId', requireRole('teacher','admin'), requirePermission('library.folders'), ctrl.unassignFolder);
router.patch('/:id/move', requireRole('teacher','admin'), ctrl.moveFile);
router.get('/:id/download', ctrl.downloadFile);
router.delete('/:id', requireRole('teacher','admin'), ctrl.deleteFile);
router.get('/:id/access', requireRole('teacher','admin'), ctrl.getFileAccess);
router.post('/:id/assign', requireRole('teacher','admin'), ctrl.assignFile);
router.delete('/:id/assign/:type/:targetId', requireRole('teacher','admin'), ctrl.unassignFile);
module.exports = router;
+21
View File
@@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const fc = require('../controllers/flashcardController');
const { authMiddleware } = require('../middleware/auth');
router.use(authMiddleware);
router.get ('/decks', fc.listDecks);
router.post ('/decks', fc.createDeck);
router.put ('/decks/:id', fc.updateDeck);
router.delete('/decks/:id', fc.deleteDeck);
router.get ('/decks/:id/cards', fc.getCards);
router.post ('/decks/:id/cards', fc.addCard);
router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
router.get ('/decks/:id/study', fc.getStudySession);
router.put ('/cards/:id', fc.updateCard);
router.delete('/cards/:id', fc.deleteCard);
router.post ('/cards/:id/review', fc.submitReview);
router.get ('/stats', fc.getStats);
module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/gamesController');
router.get('/hangman/word', authMiddleware, c.hangmanWord);
router.post('/hangman/complete', authMiddleware, c.hangmanComplete);
router.get('/crossword/generate', authMiddleware, c.crosswordGenerate);
router.post('/crossword/complete', authMiddleware, c.crosswordComplete);
module.exports = router;
+40
View File
@@ -0,0 +1,40 @@
const router = require('express').Router();
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const validate = require('../middleware/validate');
const rateLimit = require('../middleware/rateLimit');
const {
getMe, getAchievements, getLeaderboard, getXPHistory,
getChallenges, claimChallenge, setGoalTier, getFrames, setFrame,
onLabExperiment,
adminAward, adminReset, adminGamStats, adminGetUser
} = require('../controllers/gamificationController');
const labLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком частые запросы лаборатории' });
const labSchema = { body: { reactionsDiscovered: { type: 'number', min: 0, max: 100, integer: true } } };
router.use(authMiddleware);
router.get('/me', getMe);
router.get('/achievements', getAchievements);
router.get('/leaderboard', getLeaderboard);
router.get('/xp-history', getXPHistory);
router.get('/challenges', getChallenges);
router.post('/challenges/:id/claim', requirePermission('gamification.challenges'), claimChallenge);
router.post('/goal-tier', requirePermission('gamification.challenges'), setGoalTier);
router.get('/frames', getFrames);
router.post('/frame', requirePermission('shop.purchase'), setFrame);
/* Lab experiment tracking */
router.post('/lab-activity', requirePermission('simulations.access'), labLimiter, validate(labSchema), (req, res) => {
const discovered = Number(req.body.reactionsDiscovered) || 0;
onLabExperiment(req.user.id, discovered);
res.json({ ok: true });
});
/* Admin routes */
router.post('/admin/award', requireRole('admin', 'teacher'), adminAward);
router.post('/admin/reset', requireRole('admin'), adminReset);
router.get('/admin/stats', requireRole('admin', 'teacher'), adminGamStats);
router.get('/admin/user/:id', requireRole('admin', 'teacher'), adminGetUser);
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/knowledgeMapController');
router.get('/', authMiddleware, c.getMap);
module.exports = router;
+21
View File
@@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const c = require('../controllers/lessonController');
router.use(authMiddleware);
router.get('/:id', c.get);
router.post('/:id/complete', c.markComplete);
router.put('/:id/note', c.saveNote);
router.get('/:id/comments', c.listComments);
router.post('/:id/comments', c.addComment);
router.delete('/:id/comments/:cid', c.deleteComment);
// Teacher/admin only
router.post('/', requireRole('teacher','admin'), c.create);
router.put('/:id', requireRole('teacher','admin'), c.update);
router.delete('/:id', requireRole('teacher','admin'), c.remove);
router.put('/:id/blocks', requireRole('teacher','admin'), c.saveBlocks);
module.exports = router;
+16
View File
@@ -0,0 +1,16 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const c = require('../controllers/liveController');
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
router.post('/', ...teacher, c.create);
router.get('/:id', ...teacher, c.getSession);
router.put('/:id/question', ...teacher, c.setQuestion);
router.get('/:id/results', ...teacher, c.results);
router.delete('/:id', ...teacher, c.end);
router.post('/:id/answer', authMiddleware, c.answer);
router.get('/class/:classId/active', authMiddleware, c.getActive);
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const ctrl = require('../controllers/notificationController');
// SSE stream — auth done inside handler via ?token param (EventSource can't set headers)
router.get('/stream', ctrl.stream);
router.use(authMiddleware);
router.get('/', ctrl.list);
router.post('/read-all', ctrl.markAllRead);
router.patch('/:id/read', ctrl.markRead);
module.exports = router;
+26
View File
@@ -0,0 +1,26 @@
const router = require('express').Router();
const rateLimit = require('../middleware/rateLimit');
const { authMiddleware, requireRole, parentAuth } = require('../middleware/auth');
const ctrl = require('../controllers/parentController');
/* ── Rate limits ───────────────────────────────────────────────────── */
const authLimiter = rateLimit({ windowMs: 60_000, max: 5, message: 'Слишком много попыток, подождите минуту' });
const parentLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком много запросов' });
/* ── Public: token exchange ────────────────────────────────────────── */
router.post('/auth', authLimiter, ctrl.exchangeToken);
/* ── Student-side: manage parent links ─────────────────────────────── */
router.get('/my-links', authMiddleware, requireRole('student', 'free_student'), ctrl.getMyLinks);
router.post('/links', authMiddleware, requireRole('student', 'free_student'), ctrl.createLink);
router.patch('/links/:id', authMiddleware, requireRole('student', 'free_student'), ctrl.updateLink);
router.delete('/links/:id', authMiddleware, requireRole('student', 'free_student'), ctrl.deleteLink);
/* ── Parent-side: read-only dashboard (rate-limited) ───────────────── */
router.use(parentAuth, parentLimiter);
router.get('/dashboard', ctrl.getDashboard);
router.get('/history', ctrl.getHistory);
router.get('/notifications', ctrl.getNotifications);
router.patch('/notifications/:id/read', ctrl.markRead);
module.exports = router;
+20
View File
@@ -0,0 +1,20 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { getPermissions, setPermission, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions } = require('../controllers/permissionsController');
router.use(authMiddleware);
/* Any authenticated user can fetch their own effective permissions */
router.get('/me', getMyPermissions);
router.use(requireRole('admin'));
router.get('/', getPermissions);
router.post('/', setPermission);
/* ── Per-user overrides ── */
router.get('/users/:id', getUserPermissions);
router.post('/users/:id', setUserPermission);
router.delete('/users/:id/reset', resetUserPermissions);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/petController');
router.get('/', authMiddleware, c.getPet);
router.patch('/name', authMiddleware, c.renamePet);
router.post('/pet', authMiddleware, c.petAction);
router.patch('/color', authMiddleware, c.updateColor);
router.post('/star', authMiddleware, c.starCatch);
router.get('/shop', authMiddleware, c.getShop);
router.post('/shop/buy', authMiddleware, c.buyBg);
router.patch('/bg', authMiddleware, c.setBg);
router.post('/feed', authMiddleware, c.feedPet);
module.exports = router;
+18
View File
@@ -0,0 +1,18 @@
const router = require('express').Router();
const multer = require('multer');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const { list, create, duplicate, update, remove, importCSV } = require('../controllers/questionController');
const csvUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
router.use(authMiddleware);
router.use(requireRole('admin', 'teacher'));
router.get('/', list);
router.post('/import', requirePermission('questions.manage'), csvUpload.single('file'), importCSV);
router.post('/', requirePermission('questions.manage'), create);
router.post('/:id/copy', requirePermission('questions.manage'), duplicate);
router.put('/:id', requirePermission('questions.manage'), update);
router.delete('/:id', requireRole('admin', 'teacher'), requirePermission('questions.delete'), remove);
module.exports = router;
+25
View File
@@ -0,0 +1,25 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/redBookController');
const { requireAuth, optionalAuth } = require('../middleware/auth');
// Public (or optional auth for collection status)
router.get('/groups', ctrl.getGroups);
router.get('/habitats', ctrl.getHabitats);
router.get('/stats', optionalAuth, ctrl.getStats);
router.get('/map-data', ctrl.getMapData);
router.get('/food-web', ctrl.getFoodWeb);
router.get('/daily', optionalAuth, ctrl.getDaily);
router.get('/species', optionalAuth, ctrl.getSpecies);
router.get('/species/:id', optionalAuth, ctrl.getSpeciesById);
router.get('/biome/:habitatId', ctrl.getBiomeSpecies);
// Auth required
router.post('/species/:id/collect', requireAuth, ctrl.collectSpecies);
router.get('/collection', requireAuth, ctrl.getCollection);
router.get('/quests', optionalAuth, ctrl.getQuests);
router.post('/quests/:id/start', requireAuth, ctrl.startQuest);
router.get('/sightings', optionalAuth, ctrl.getSightings);
router.post('/sightings', requireAuth, ctrl.addSighting);
module.exports = router;
+8
View File
@@ -0,0 +1,8 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const ctrl = require('../controllers/searchController');
router.use(authMiddleware);
router.get('/', ctrl.search);
module.exports = router;
+28
View File
@@ -0,0 +1,28 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const validate = require('../middleware/validate');
const { start, answer, finish, result, history, weakTopics, getSessionQuestions, stats } = require('../controllers/sessionController');
const sessionLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много сессий, подождите минуту' });
const startSchema = { body: {
subject_slug: { type: 'string', required: true, minLen: 1, maxLen: 100 },
mode: { type: 'string', oneOf: ['exam', 'practice', 'repeat', 'ct'] },
count: { type: 'number', min: 1, max: 200, integer: true },
}};
const answerSchema = { body: {
question_id: { type: 'number', required: true, min: 1, integer: true },
}};
router.use(authMiddleware); // все маршруты требуют авторизации
router.post('/', sessionLimiter, validate(startSchema), start);
router.post('/:id/answer', validate(answerSchema), answer);
router.post('/:id/finish', finish);
router.get('/history', history);
router.get('/stats', stats);
router.get('/weak-topics', weakTopics);
router.get('/:id/result', result);
router.get('/:id/questions', getSessionQuestions);
module.exports = router;
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { getSimSettings, updateSimSettings } = require('../controllers/settingsController');
// GET is open to any authenticated user (students need to check disabled sims)
router.get('/sims', authMiddleware, getSimSettings);
// PUT requires admin
router.put('/sims', authMiddleware, requireRole('admin'), updateSimSettings);
module.exports = router;
+39
View File
@@ -0,0 +1,39 @@
const router = require('express').Router();
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const validate = require('../middleware/validate');
const {
getItems, purchaseItem, getPurchases, getCoins, getMyActive, activateItem,
adminGetItems, adminCreateItem, adminUpdateItem, adminDeleteItem, adminAwardCoins, adminShopStats
} = require('../controllers/shopController');
const purchaseLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много покупок, подождите минуту' });
const activateSchema = { body: { type: { type: 'string', oneOf: ['frame', 'title', 'effect'] } } };
const adminItemSchema = { body: {
name: { type: 'string', required: true, minLen: 1, maxLen: 200 },
type: { type: 'string', required: true, oneOf: ['frame', 'title', 'effect'] },
price: { type: 'number', required: true, min: 0 },
}};
const awardCoinsSchema = { body: {
userId: { type: 'number', required: true, min: 1, integer: true },
amount: { type: 'number', required: true, min: 1, integer: true },
}};
router.use(authMiddleware);
router.get('/items', getItems);
router.post('/items/:id/purchase', requirePermission('shop.purchase'), purchaseLimiter, purchaseItem);
router.get('/purchases', getPurchases);
router.get('/coins', getCoins);
router.get('/my-active', getMyActive);
router.post('/activate', validate(activateSchema), activateItem);
/* Admin routes */
router.get('/admin/items', requireRole('admin', 'teacher'), adminGetItems);
router.post('/admin/items', requireRole('admin'), validate(adminItemSchema), adminCreateItem);
router.put('/admin/items/:id', requireRole('admin'), adminUpdateItem);
router.delete('/admin/items/:id',requireRole('admin'), adminDeleteItem);
router.post('/admin/award-coins',requireRole('admin', 'teacher'), validate(awardCoinsSchema), adminAwardCoins);
router.get('/admin/stats', requireRole('admin', 'teacher'), adminShopStats);
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
router.get('/', (_req, res) => {
res.json(db.prepare(`
SELECT s.*, (SELECT COUNT(*) FROM questions q WHERE q.subject_id = s.id) AS question_count
FROM subjects s ORDER BY s.id
`).all());
});
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
const { default_mode, default_count, default_test_id } = req.body;
const valid_modes = ['exam', 'practice', 'topic', 'random'];
if (default_mode && !valid_modes.includes(default_mode))
return res.status(400).json({ error: 'Invalid mode' });
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(req.params.slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
const updates = [];
const args = [];
if (default_mode !== undefined) { updates.push('default_mode = ?'); args.push(default_mode); }
if (default_count !== undefined) { updates.push('default_count = ?'); args.push(Number(default_count) || 25); }
if (default_test_id !== undefined) { updates.push('default_test_id = ?'); args.push(default_test_id || null); }
if (!updates.length) return res.status(400).json({ error: 'Nothing to update' });
args.push(req.params.slug);
db.prepare(`UPDATE subjects SET ${updates.join(', ')} WHERE slug = ?`).run(...args);
res.json(db.prepare('SELECT * FROM subjects WHERE slug = ?').get(req.params.slug));
});
router.get('/:slug/topics', (req, res) => {
const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(req.params.slug);
if (!subject) return res.status(404).json({ error: 'Subject not found' });
const topics = db.prepare(
'SELECT MIN(id) AS id, subject_id, name, MIN(order_index) AS order_index FROM topics WHERE subject_id = ? GROUP BY name ORDER BY MIN(order_index)'
).all(subject.id);
res.json(topics);
});
module.exports = router;
+60
View File
@@ -0,0 +1,60 @@
const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const { authMiddleware, requireRole } = require('../middleware/auth');
const ctrl = require('../controllers/submissionsController');
const { fixUtf8Name } = require('../utils/fixUtf8');
/* ── multer — same dir/types as library uploads ─────────────────────── */
const UPLOADS_DIR = path.join(__dirname, '../../uploads');
const ALLOWED = [
'application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'image/webp',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
];
const SAFE_EXTS = new Set(['.pdf','.png','.jpg','.jpeg','.gif','.webp','.doc','.docx','.ppt','.pptx','.xls','.xlsx','.txt']);
const storage = multer.diskStorage({
destination: UPLOADS_DIR,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
const name = require('crypto').randomBytes(16).toString('hex') + ext;
cb(null, name);
},
});
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!ALLOWED.includes(file.mimetype)) return cb(null, false);
// Reject double extensions (.php.jpg, .exe.pdf, etc.)
const name = file.originalname;
const parts = name.split('.');
if (parts.length > 2) {
const inner = '.' + parts[parts.length - 2].toLowerCase();
if (['.php','.exe','.sh','.bat','.cmd','.ps1','.js','.html','.htm'].includes(inner)) return cb(null, false);
}
const ext = path.extname(name).toLowerCase();
if (ext && !SAFE_EXTS.has(ext)) return cb(null, false);
cb(null, true);
},
});
/* ── routes ─────────────────────────────────────────────────────────── */
router.use(authMiddleware);
router.post('/', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.submit);
router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions);
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog);
router.get('/', requireRole('teacher', 'admin'), ctrl.getClassSubmissions);
router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission);
router.get('/:id/download', ctrl.downloadSubmission);
router.delete('/:id', ctrl.deleteSubmission);
router.post('/:id/resubmit', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
module.exports = router;
+17
View File
@@ -0,0 +1,17 @@
const router = require('express').Router();
const { authMiddleware, requirePermission } = require('../middleware/auth');
const ctrl = require('../controllers/templateController');
router.use(authMiddleware);
router.get('/courses', ctrl.listCourseTemplates);
router.post('/courses', requirePermission('templates.manage'), ctrl.saveCourseTemplate);
router.post('/courses/:id/create', requirePermission('templates.manage'), ctrl.createFromCourseTemplate);
router.delete('/courses/:id', requirePermission('templates.manage'), ctrl.deleteCourseTemplate);
router.get('/lessons', ctrl.listLessonTemplates);
router.post('/lessons', requirePermission('templates.manage'), ctrl.saveLessonTemplate);
router.post('/lessons/:id/create', requirePermission('templates.manage'), ctrl.createFromLessonTemplate);
router.delete('/lessons/:id', requirePermission('templates.manage'), ctrl.deleteLessonTemplate);
module.exports = router;
+19
View File
@@ -0,0 +1,19 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { requireOwnership } = require('../middleware/ownership');
const ctrl = require('../controllers/testController');
const ownsTest = requireOwnership({ table: 'tests', ownerField: 'created_by' });
router.use(authMiddleware);
router.get('/', ctrl.list);
router.post('/', requireRole('teacher','admin'), ctrl.create);
router.get('/:id', ctrl.getOne);
router.put('/:id', requireRole('teacher','admin'), ownsTest, ctrl.update);
router.delete('/:id', requireRole('teacher','admin'), ownsTest, ctrl.remove);
router.post('/:id/questions', requireRole('teacher','admin'), ownsTest, ctrl.addQuestions);
router.patch('/:id/questions/reorder', requireRole('teacher','admin'), ownsTest, ctrl.reorderQuestions);
router.delete('/:id/questions/:qid', requireRole('teacher','admin'), ownsTest, ctrl.removeQuestion);
module.exports = router;