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
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const db = require('../db/db');
const stmt = db.prepare(
"INSERT INTO admin_audit_log (admin_id, action, target, detail, ip) VALUES (?, ?, ?, ?, ?)"
);
/**
* Log an admin action.
* @param {object} req - Express request (must have req.user)
* @param {string} action - e.g. 'user.role_change', 'user.delete', 'user.ban'
* @param {string} [target] - e.g. 'user:42', 'question:15'
* @param {string} [detail] - human-readable detail
*/
function audit(req, action, target, detail) {
try {
const ip = req.ip || req.socket?.remoteAddress || '';
stmt.run(req.user?.id || 0, action, target || null, detail || null, ip);
} catch (e) { console.error('[audit]', e.message); }
}
module.exports = { audit };
+15
View File
@@ -0,0 +1,15 @@
/**
* Multer encodes originalname as latin1 (ISO-8859-1).
* Browsers send filenames as UTF-8 bytes, so non-ASCII chars get mangled.
* This middleware re-decodes the bytes back to proper UTF-8.
*/
function fixUtf8Name(req, _res, next) {
if (req.file && req.file.originalname) {
try {
req.file.originalname = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
} catch { /* keep as-is if decoding fails */ }
}
next();
}
module.exports = { fixUtf8Name };
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/* ── Structured logger — JSON in prod, pretty in dev ──────────────────────
Usage: logger.info('msg', { key: val })
logger.error('msg', { err: e.message })
──────────────────────────────────────────────────────────────────────── */
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
const COLORS = {
error: '\x1b[31m', // red
warn: '\x1b[33m', // yellow
info: '\x1b[36m', // cyan
debug: '\x1b[90m', // gray
};
const RESET = '\x1b[0m';
const isProd = process.env.NODE_ENV === 'production';
function _currentLevel() {
const env = process.env.LOG_LEVEL;
if (env && LEVELS[env] !== undefined) return LEVELS[env];
return isProd ? LEVELS.info : LEVELS.debug;
}
function log(level, msg, meta) {
if (LEVELS[level] > _currentLevel()) return;
const ts = new Date().toISOString();
if (isProd) {
/* JSON — one line per entry, parseable by log aggregators */
const entry = { level, ts, msg };
if (meta && typeof meta === 'object') Object.assign(entry, meta);
process.stdout.write(JSON.stringify(entry) + '\n');
} else {
/* Pretty — coloured label + message + optional meta */
const color = COLORS[level] || '';
const label = `[${ts}] ${color}${level.toUpperCase().padEnd(5)}${RESET}`;
const metaStr = meta && Object.keys(meta).length
? ' ' + JSON.stringify(meta, null, 0)
: '';
process.stdout.write(`${label} ${msg}${metaStr}\n`);
}
}
const logger = {
error: (msg, meta) => log('error', msg, meta),
warn: (msg, meta) => log('warn', msg, meta),
info: (msg, meta) => log('info', msg, meta),
debug: (msg, meta) => log('debug', msg, meta),
/* Convenience: log an Error object */
exception: (msg, err, meta) => log('error', msg, {
err: err?.message,
stack: !isProd ? err?.stack : undefined,
...meta,
}),
};
module.exports = logger;
+38
View File
@@ -0,0 +1,38 @@
'use strict';
/* ── Magic bytes validation (shared between file and submission uploads) ── */
const fs = require('fs');
const MAGIC = [
{ mime: 'application/pdf', bytes: [0x25,0x50,0x44,0x46], offset: 0 }, // %PDF
{ mime: 'image/png', bytes: [0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A], offset: 0 },
{ mime: 'image/jpeg', bytes: [0xFF,0xD8,0xFF], offset: 0 },
{ mime: 'image/gif', bytes: [0x47,0x49,0x46,0x38], offset: 0 }, // GIF8
{ mime: 'image/webp', bytes: [0x57,0x45,0x42,0x50], offset: 8 }, // RIFF????WEBP
// OLE2 (doc, xls, ppt)
{ mime: 'application/msword', bytes: [0xD0,0xCF,0x11,0xE0], offset: 0 },
{ mime: 'application/vnd.ms-excel', bytes: [0xD0,0xCF,0x11,0xE0], offset: 0 },
{ mime: 'application/vnd.ms-powerpoint', bytes: [0xD0,0xCF,0x11,0xE0], offset: 0 },
// ZIP/OOXML (docx, xlsx, pptx)
{ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
{ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
];
function checkMagicBytes(filePath, declaredMime) {
if (declaredMime === 'text/plain') return true; // txt has no magic bytes
const rules = MAGIC.filter(m => m.mime === declaredMime);
if (!rules.length) return false; // unknown mime → reject
try {
const needed = Math.max(...rules.map(r => r.offset + r.bytes.length));
const buf = Buffer.alloc(needed);
const fd = fs.openSync(filePath, 'r');
const read = fs.readSync(fd, buf, 0, needed, 0);
fs.closeSync(fd);
if (read < needed) return false;
return rules.some(r => r.bytes.every((b, i) => buf[r.offset + i] === b));
} catch {
return false;
}
}
module.exports = { checkMagicBytes };
+26
View File
@@ -0,0 +1,26 @@
const db = require('../db/db');
const sse = require('../sse');
function pushNotif(user_id, type, message, link) {
try {
const { lastInsertRowid } = db.prepare(
"INSERT INTO notifications (user_id, type, message, link) VALUES (?, ?, ?, ?)"
).run(user_id, type, message, link || null);
sse.emit(user_id, { id: lastInsertRowid, type, message, link: link || null });
} catch (e) { console.error('[pushNotif]', e.message); }
}
/* ── Notify all active parent links for a student ────────────────────── */
function pushParentNotif(studentId, type, message) {
try {
const links = db.prepare(
'SELECT id FROM parent_links WHERE student_id = ? AND is_active = 1'
).all(studentId);
const ins = db.prepare(
'INSERT INTO parent_notifications (parent_link_id, type, message) VALUES (?, ?, ?)'
);
for (const link of links) ins.run(link.id, type, message);
} catch (e) { console.error('[pushParentNotif]', e.message); }
}
module.exports = { pushNotif, pushParentNotif };
+27
View File
@@ -0,0 +1,27 @@
/* ── Shared input sanitization ────────────────────────────────────────── */
/**
* Strip HTML tags from a string.
* Use on user-supplied text that will be stored or rendered.
*/
function stripTags(str) {
if (typeof str !== 'string') return str;
let s = str;
let prev;
do { prev = s; s = s.replace(/<[^>]*>?/g, ''); } while (s !== prev);
return s.trim();
}
/**
* Sanitize an object's string fields in-place.
* @param {object} obj
* @param {string[]} fields — keys to sanitize
*/
function sanitizeFields(obj, fields) {
for (const f of fields) {
if (typeof obj[f] === 'string') obj[f] = stripTags(obj[f]);
}
return obj;
}
module.exports = { stripTags, sanitizeFields };