b2de630983
- Require JWT_SECRET env var (fatal exit if missing) - Add helmet middleware with custom CSP - Cookie Secure flag when NODE_ENV=production - requireAuth re-verifies user.status from DB on every request - class_events DELETE restricted to creator or admin - Rate limit /register (5/hr) and PUT /me/password (5/15min) - Password minimum 6 to 8 chars - crudRoutes truncates strings to 1000 chars - Remove application/octet-stream from allowed upload MIMEs
281 lines
15 KiB
JavaScript
281 lines
15 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
const db = require('./db');
|
|
const { requireAuth } = require('./auth');
|
|
|
|
const router = express.Router();
|
|
|
|
const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges'];
|
|
const VALID_TYPES = ['schulaufgabe','kurzarbeit','stegreifaufgabe','muendlich','sonstiges'];
|
|
const CLASS_IDS = ['info1'];
|
|
|
|
function requireTeacher(req, res, next) {
|
|
if (req.user.role !== 'teacher') return res.status(403).json({ error: 'Nur für Lehrkräfte' });
|
|
const user = db.prepare('SELECT status, subject FROM users WHERE id = ?').get(req.user.id);
|
|
if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' });
|
|
if (!user.subject) return res.status(403).json({ error: 'Kein Lehrfach zugewiesen' });
|
|
req.teacher = user;
|
|
next();
|
|
}
|
|
|
|
const STORAGE_DIR = path.resolve(__dirname, '../storage');
|
|
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
|
|
|
const ALLOWED_EXT = new Set([
|
|
'pdf','doc','docx','xls','xlsx','ppt','pptx','odt','ods','odp','rtf','txt','md',
|
|
'jpg','jpeg','png','gif','webp','bmp','tiff',
|
|
'zip','rar','7z','csv',
|
|
]);
|
|
const ALLOWED_MIME = new Set([
|
|
'application/pdf','application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/vnd.oasis.opendocument.text',
|
|
'application/vnd.oasis.opendocument.spreadsheet',
|
|
'application/vnd.oasis.opendocument.presentation',
|
|
'application/rtf','text/rtf','text/plain','text/markdown',
|
|
'image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff',
|
|
'application/zip','application/x-zip-compressed',
|
|
'application/vnd.rar','application/x-rar-compressed',
|
|
'application/x-7z-compressed','text/csv',
|
|
]);
|
|
const EXT_MIME = {
|
|
pdf:'application/pdf', doc:'application/msword',
|
|
docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
xls:'application/vnd.ms-excel',
|
|
xlsx:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
ppt:'application/vnd.ms-powerpoint',
|
|
pptx:'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
odt:'application/vnd.oasis.opendocument.text',
|
|
ods:'application/vnd.oasis.opendocument.spreadsheet',
|
|
odp:'application/vnd.oasis.opendocument.presentation',
|
|
rtf:'application/rtf', txt:'text/plain', md:'text/plain',
|
|
jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif',
|
|
webp:'image/webp', bmp:'image/bmp', tiff:'image/tiff',
|
|
zip:'application/zip', rar:'application/vnd.rar',
|
|
'7z':'application/x-7z-compressed', csv:'text/csv',
|
|
};
|
|
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, STORAGE_DIR),
|
|
filename: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase().slice(1);
|
|
cb(null, crypto.randomUUID() + (ext ? '.' + ext : ''));
|
|
},
|
|
}),
|
|
limits: { fileSize: 50 * 1024 * 1024 },
|
|
fileFilter(_req, file, cb) {
|
|
const ext = path.extname(file.originalname).toLowerCase().slice(1);
|
|
if (!ext || !ALLOWED_EXT.has(ext))
|
|
return cb(Object.assign(new Error('INVALID_EXT'), { code: 'INVALID_EXT' }));
|
|
if (file.mimetype && !ALLOWED_MIME.has(file.mimetype))
|
|
return cb(Object.assign(new Error('INVALID_MIME'), { code: 'INVALID_MIME' }));
|
|
cb(null, true);
|
|
},
|
|
});
|
|
|
|
// ── MATERIALS ──────────────────────────────────────────────────
|
|
|
|
router.get('/materials', requireAuth, requireTeacher, (req, res) => {
|
|
res.json(db.prepare(
|
|
'SELECT id, subject, class_id, title, original_name, mime_type, size, created_at FROM teacher_materials WHERE teacher_id = ? ORDER BY created_at DESC'
|
|
).all(req.user.id));
|
|
});
|
|
|
|
router.post('/materials', requireAuth, requireTeacher, (req, res) => {
|
|
upload.single('file')(req, res, (err) => {
|
|
if (err) {
|
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'Datei zu groß (max. 50 MB)' });
|
|
if (err.code === 'INVALID_EXT') return res.status(400).json({ error: 'Dateityp nicht erlaubt' });
|
|
if (err.code === 'INVALID_MIME') return res.status(400).json({ error: 'MIME-Typ nicht erlaubt' });
|
|
return res.status(400).json({ error: 'Upload fehlgeschlagen' });
|
|
}
|
|
if (!req.file) return res.status(400).json({ error: 'Keine Datei angegeben' });
|
|
|
|
const title = typeof req.body.title === 'string' ? req.body.title.trim() : '';
|
|
if (!title || title.length > 200) {
|
|
fs.unlink(req.file.path, () => {});
|
|
return res.status(400).json({ error: 'Titel erforderlich (max. 200 Zeichen)' });
|
|
}
|
|
const safeClass = CLASS_IDS.includes(req.body.class_id) ? req.body.class_id : 'info1';
|
|
const ext = path.extname(req.file.originalname).toLowerCase().slice(1);
|
|
const mime = EXT_MIME[ext] || 'application/octet-stream';
|
|
|
|
const result = db.prepare(
|
|
'INSERT INTO teacher_materials (teacher_id, subject, class_id, title, original_name, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
).run(req.user.id, req.teacher.subject, safeClass, title, req.file.originalname, req.file.filename, mime, req.file.size);
|
|
|
|
res.json({ id: result.lastInsertRowid, title, original_name: req.file.originalname, mime_type: mime, size: req.file.size });
|
|
});
|
|
});
|
|
|
|
router.delete('/materials/:id', requireAuth, requireTeacher, (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
|
const mat = db.prepare('SELECT * FROM teacher_materials WHERE id = ? AND teacher_id = ?').get(id, req.user.id);
|
|
if (!mat) return res.status(404).json({ error: 'Material nicht gefunden' });
|
|
fs.unlink(path.join(STORAGE_DIR, mat.stored_name), () => {});
|
|
db.prepare('DELETE FROM teacher_materials WHERE id = ?').run(id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.get('/materials/class/:class_id', requireAuth, (req, res) => {
|
|
const classId = CLASS_IDS.includes(req.params.class_id) ? req.params.class_id : 'info1';
|
|
res.json(db.prepare(`
|
|
SELECT m.id, m.subject, m.title, m.original_name, m.mime_type, m.size, m.created_at, u.username AS teacher_name
|
|
FROM teacher_materials m JOIN users u ON u.id = m.teacher_id
|
|
WHERE m.class_id = ? ORDER BY m.created_at DESC
|
|
`).all(classId));
|
|
});
|
|
|
|
router.get('/materials/:id/download', requireAuth, (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
|
const mat = db.prepare('SELECT * FROM teacher_materials WHERE id = ?').get(id);
|
|
if (!mat) return res.status(404).json({ error: 'Material nicht gefunden' });
|
|
const filePath = path.join(STORAGE_DIR, mat.stored_name);
|
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
|
res.setHeader('Content-Type', mat.mime_type);
|
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(mat.original_name)}`);
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('Cache-Control', 'private, no-cache');
|
|
res.sendFile(filePath);
|
|
});
|
|
|
|
// ── ANNOUNCEMENTS ──────────────────────────────────────────────
|
|
|
|
router.get('/announcements', requireAuth, requireTeacher, (req, res) => {
|
|
res.json(db.prepare(
|
|
'SELECT id, subject, class_id, title, content, created_at FROM teacher_announcements WHERE teacher_id = ? ORDER BY created_at DESC'
|
|
).all(req.user.id));
|
|
});
|
|
|
|
router.post('/announcements', requireAuth, requireTeacher, (req, res) => {
|
|
const { title, content, class_id } = req.body;
|
|
if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' });
|
|
if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Inhalt erforderlich' });
|
|
const safeTitle = title.trim();
|
|
const safeContent = content.trim();
|
|
if (!safeTitle || safeTitle.length > 200) return res.status(400).json({ error: 'Titel ungültig (max. 200 Zeichen)' });
|
|
if (!safeContent || safeContent.length > 5000) return res.status(400).json({ error: 'Inhalt zu lang (max. 5000 Zeichen)' });
|
|
const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1';
|
|
const result = db.prepare(
|
|
'INSERT INTO teacher_announcements (teacher_id, subject, class_id, title, content) VALUES (?, ?, ?, ?, ?)'
|
|
).run(req.user.id, req.teacher.subject, safeClass, safeTitle, safeContent);
|
|
res.json({ id: result.lastInsertRowid });
|
|
});
|
|
|
|
router.delete('/announcements/:id', requireAuth, requireTeacher, (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
|
const ann = db.prepare('SELECT id FROM teacher_announcements WHERE id = ? AND teacher_id = ?').get(id, req.user.id);
|
|
if (!ann) return res.status(404).json({ error: 'Ankündigung nicht gefunden' });
|
|
db.prepare('DELETE FROM teacher_announcements WHERE id = ?').run(id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.get('/announcements/class/:class_id', requireAuth, (req, res) => {
|
|
const classId = CLASS_IDS.includes(req.params.class_id) ? req.params.class_id : 'info1';
|
|
res.json(db.prepare(`
|
|
SELECT a.id, a.subject, a.title, a.content, a.created_at, u.username AS teacher_name
|
|
FROM teacher_announcements a JOIN users u ON u.id = a.teacher_id
|
|
WHERE a.class_id = ? ORDER BY a.created_at DESC
|
|
`).all(classId));
|
|
});
|
|
|
|
// ── EXAMS ──────────────────────────────────────────────────────
|
|
|
|
router.get('/exams', requireAuth, requireTeacher, (req, res) => {
|
|
res.json(db.prepare(
|
|
'SELECT id, subject, class_id, title, date, description, created_at FROM teacher_exams WHERE teacher_id = ? ORDER BY date ASC NULLS LAST'
|
|
).all(req.user.id));
|
|
});
|
|
|
|
router.post('/exams', requireAuth, requireTeacher, (req, res) => {
|
|
const { title, date, description, class_id } = req.body;
|
|
if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' });
|
|
const safeTitle = title.trim();
|
|
if (!safeTitle || safeTitle.length > 200) return res.status(400).json({ error: 'Titel ungültig (max. 200 Zeichen)' });
|
|
const safeDate = date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
|
|
const safeDesc = description && typeof description === 'string' ? description.trim().slice(0, 1000) : null;
|
|
const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1';
|
|
const result = db.prepare(
|
|
'INSERT INTO teacher_exams (teacher_id, subject, class_id, title, date, description) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(req.user.id, req.teacher.subject, safeClass, safeTitle, safeDate, safeDesc);
|
|
res.json({ id: result.lastInsertRowid });
|
|
});
|
|
|
|
router.delete('/exams/:id', requireAuth, requireTeacher, (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
|
const exam = db.prepare('SELECT id FROM teacher_exams WHERE id = ? AND teacher_id = ?').get(id, req.user.id);
|
|
if (!exam) return res.status(404).json({ error: 'Prüfung nicht gefunden' });
|
|
db.prepare('DELETE FROM teacher_exams WHERE id = ?').run(id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.get('/exams/class/:class_id', requireAuth, (req, res) => {
|
|
const classId = CLASS_IDS.includes(req.params.class_id) ? req.params.class_id : 'info1';
|
|
res.json(db.prepare(`
|
|
SELECT e.id, e.subject, e.title, e.date, e.description, e.created_at, u.username AS teacher_name
|
|
FROM teacher_exams e JOIN users u ON u.id = e.teacher_id
|
|
WHERE e.class_id = ? ORDER BY e.date ASC NULLS LAST
|
|
`).all(classId));
|
|
});
|
|
|
|
// ── GRADES ─────────────────────────────────────────────────────
|
|
|
|
router.get('/grades', requireAuth, requireTeacher, (req, res) => {
|
|
res.json(db.prepare(`
|
|
SELECT g.id, g.student_id, g.subject, g.grade, g.type, g.note, g.created_at, u.username AS student_name
|
|
FROM teacher_assigned_grades g JOIN users u ON u.id = g.student_id
|
|
WHERE g.teacher_id = ? ORDER BY g.created_at DESC
|
|
`).all(req.user.id));
|
|
});
|
|
|
|
router.post('/grades', requireAuth, requireTeacher, (req, res) => {
|
|
const { student_id, grade, type, note } = req.body;
|
|
if (!student_id) return res.status(400).json({ error: 'Schüler erforderlich' });
|
|
const gradeNum = parseFloat(grade);
|
|
if (isNaN(gradeNum) || gradeNum < 1 || gradeNum > 6) return res.status(400).json({ error: 'Note muss zwischen 1 und 6 liegen' });
|
|
const safeType = VALID_TYPES.includes(type) ? type : 'sonstiges';
|
|
const safeNote = note && typeof note === 'string' ? note.trim().slice(0, 500) : null;
|
|
const student = db.prepare("SELECT id, role FROM users WHERE id = ? AND status = 'active'").get(parseInt(student_id, 10));
|
|
if (!student || student.role !== 'student') return res.status(404).json({ error: 'Schüler nicht gefunden' });
|
|
const result = db.prepare(
|
|
'INSERT INTO teacher_assigned_grades (teacher_id, student_id, subject, grade, type, note) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(req.user.id, student.id, req.teacher.subject, gradeNum, safeType, safeNote);
|
|
res.json({ id: result.lastInsertRowid });
|
|
});
|
|
|
|
router.delete('/grades/:id', requireAuth, requireTeacher, (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
|
const gr = db.prepare('SELECT id FROM teacher_assigned_grades WHERE id = ? AND teacher_id = ?').get(id, req.user.id);
|
|
if (!gr) return res.status(404).json({ error: 'Note nicht gefunden' });
|
|
db.prepare('DELETE FROM teacher_assigned_grades WHERE id = ?').run(id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.get('/students', requireAuth, requireTeacher, (req, res) => {
|
|
res.json(db.prepare("SELECT id, username FROM users WHERE role = 'student' AND status = 'active' ORDER BY username").all());
|
|
});
|
|
|
|
router.get('/my-grades', requireAuth, (req, res) => {
|
|
if (req.user.role !== 'student') return res.status(403).json({ error: 'Nur für Schüler' });
|
|
res.json(db.prepare(`
|
|
SELECT g.id, g.subject, g.grade, g.type, g.note, g.created_at, u.username AS teacher_name
|
|
FROM teacher_assigned_grades g JOIN users u ON u.id = g.teacher_id
|
|
WHERE g.student_id = ? ORDER BY g.created_at DESC
|
|
`).all(req.user.id));
|
|
});
|
|
|
|
module.exports = router;
|