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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user