const express = require('express'); const bcrypt = require('bcryptjs'); const rateLimit = require('express-rate-limit'); const db = require('./db'); const { signToken, requireAuth } = require('./auth'); const { deleteUserFiles } = require('./files'); const router = express.Router(); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, message: { error: 'Zu viele Anmeldeversuche. Bitte 15 Minuten warten.' }, standardHeaders: true, legacyHeaders: false, }); // --- AUTH --- const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i; router.post('/register', (req, res) => { const { email, password, role } = req.body; if (!email || !password) return res.status(400).json({ error: 'Alle Felder erforderlich' }); if (!IFB_EMAIL_RE.test(email)) return res.status(403).json({ error: 'Ungültige E-Mail-Adresse' }); if (password.length < 6) return res.status(400).json({ error: 'Passwort zu kurz (min. 6 Zeichen)' }); const username = email.split('@')[0].toLowerCase(); const safeRole = (role === 'teacher') ? 'teacher' : 'student'; const initialStatus = safeRole === 'teacher' ? 'pending' : 'active'; const hash = bcrypt.hashSync(password, 12); try { const result = db.prepare('INSERT INTO users (username, email, password_hash, role, status) VALUES (?, ?, ?, ?, ?)').run(username, email.toLowerCase(), hash, safeRole, initialStatus); if (safeRole === 'teacher') { return res.json({ ok: true, pending: true }); } const user = { id: result.lastInsertRowid, username, role: safeRole }; const token = signToken(user); res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' }); res.json({ ok: true, pending: false }); } catch (e) { if (e.message.includes('UNIQUE')) { if (e.message.includes('email')) return res.status(409).json({ error: 'Diese E-Mail-Adresse ist bereits registriert' }); return res.status(409).json({ error: 'Benutzername bereits vergeben' }); } res.status(500).json({ error: 'Serverfehler' }); } }); router.post('/login', loginLimiter, (req, res) => { const { username, password } = req.body; const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user || !bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' }); } if (user.status === 'pending') { return res.status(403).json({ error: 'Dein Konto wartet noch auf Freischaltung durch einen Administrator.' }); } if (user.status === 'banned') { return res.status(403).json({ error: 'Dein Konto wurde gesperrt. Bitte wende dich an einen Administrator.' }); } if (user.status === 'rejected') { return res.status(403).json({ error: 'Deine Registrierung als Lehrer/in wurde abgelehnt.' }); } if (user.status !== 'active') { return res.status(403).json({ error: 'Dein Konto ist nicht aktiv.' }); } const token = signToken(user); res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' }); res.json({ ok: true, username: user.username, role: user.role }); }); router.post('/logout', (req, res) => { res.clearCookie('token'); res.json({ ok: true }); }); router.get('/me', requireAuth, (req, res) => { const user = db.prepare('SELECT id, username, email, role, status FROM users WHERE id = ?').get(req.user.id); if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' }); res.json({ username: user.username, id: user.id, role: user.role, email: user.email }); }); router.put('/me/password', requireAuth, (req, res) => { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Felder erforderlich' }); if (newPassword.length < 6) return res.status(400).json({ error: 'Neues Passwort zu kurz (min. 6 Zeichen)' }); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); if (!bcrypt.compareSync(currentPassword, user.password_hash)) { return res.status(401).json({ error: 'Aktuelles Passwort falsch' }); } const hash = bcrypt.hashSync(newPassword, 12); db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id); res.json({ ok: true }); }); router.delete('/me', requireAuth, (req, res) => { const { password } = req.body; const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); if (!bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ error: 'Passwort falsch' }); } const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','chat_messages']; tables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(req.user.id)); deleteUserFiles(req.user.id); db.prepare('DELETE FROM users WHERE id = ?').run(req.user.id); res.clearCookie('token'); res.json({ ok: true }); }); // --- ADMIN --- function requireAdmin(req, res, next) { if (req.user.role !== 'admin') return res.status(403).json({ error: 'Keine Administratorrechte' }); next(); } function logAdmin(adminId, action, targetId = null, details = null) { db.prepare('INSERT INTO admin_logs (admin_id, action, target_id, details) VALUES (?, ?, ?, ?)') .run(adminId, action, targetId, details ? JSON.stringify(details) : null); } router.get('/admin/users', requireAuth, requireAdmin, (req, res) => { const { role, status } = req.query; let sql = 'SELECT id, username, email, role, status, created_at FROM users'; const params = []; const conditions = []; if (role) { conditions.push('role = ?'); params.push(role); } if (status) { conditions.push('status = ?'); params.push(status); } if (conditions.length) sql += ' WHERE ' + conditions.join(' AND '); sql += ' ORDER BY created_at DESC'; res.json(db.prepare(sql).all(...params)); }); router.post('/admin/teachers/:id/approve', requireAuth, requireAdmin, (req, res) => { const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (target.role !== 'teacher') return res.status(400).json({ error: 'Kein Lehrerkonto' }); db.prepare('UPDATE users SET status = ? WHERE id = ?').run('active', req.params.id); logAdmin(req.user.id, 'teacher_approve', Number(req.params.id)); res.json({ ok: true }); }); router.post('/admin/teachers/:id/reject', requireAuth, requireAdmin, (req, res) => { const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (target.role !== 'teacher') return res.status(400).json({ error: 'Kein Lehrerkonto' }); db.prepare('UPDATE users SET status = ? WHERE id = ?').run('rejected', req.params.id); logAdmin(req.user.id, 'teacher_reject', Number(req.params.id)); res.json({ ok: true }); }); router.patch('/admin/users/:id', requireAuth, requireAdmin, (req, res) => { const { role, status } = req.body; const allowed_roles = ['student', 'teacher', 'admin']; const allowed_status = ['active', 'pending', 'banned', 'rejected']; if (role && !allowed_roles.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle' }); if (status && !allowed_status.includes(status)) return res.status(400).json({ error: 'Ungültiger Status' }); if (!role && !status) return res.status(400).json({ error: 'Keine Änderung angegeben' }); const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id); if (status) db.prepare('UPDATE users SET status = ? WHERE id = ?').run(status, req.params.id); logAdmin(req.user.id, 'user_update', Number(req.params.id), { role, status }); res.json({ ok: true }); }); router.delete('/admin/users/:id', requireAuth, requireAdmin, (req, res) => { const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (Number(req.params.id) === req.user.id) return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' }); const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','chat_messages']; tables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(req.params.id)); deleteUserFiles(Number(req.params.id)); db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); logAdmin(req.user.id, 'user_delete', Number(req.params.id)); res.json({ ok: true }); }); router.get('/admin/tickets', requireAuth, requireAdmin, (req, res) => { const tickets = db.prepare(` SELECT t.id, t.user_id, t.subject, t.message, t.status, t.created_at, u.username, u.email FROM support_tickets t JOIN users u ON u.id = t.user_id ORDER BY t.created_at DESC `).all(); res.json(tickets); }); router.patch('/admin/tickets/:id', requireAuth, requireAdmin, (req, res) => { const allowed = ['open', 'in_progress', 'closed']; const { status } = req.body; if (!status || !allowed.includes(status)) return res.status(400).json({ error: 'Ungültiger Status' }); const ticket = db.prepare('SELECT id FROM support_tickets WHERE id = ?').get(req.params.id); if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' }); db.prepare('UPDATE support_tickets SET status = ? WHERE id = ?').run(status, req.params.id); logAdmin(req.user.id, 'ticket_update', Number(req.params.id), { status }); res.json({ ok: true }); }); router.get('/admin/logs', requireAuth, requireAdmin, (req, res) => { const logs = db.prepare(` SELECT l.id, l.action, l.target_id, l.details, l.created_at, u.username AS admin_username FROM admin_logs l JOIN users u ON u.id = l.admin_id ORDER BY l.created_at DESC LIMIT 500 `).all(); res.json(logs); }); router.get('/admin/usage', requireAuth, requireAdmin, (req, res) => { const users = db.prepare('SELECT id, username, email, role, status FROM users ORDER BY username').all(); const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','support_tickets']; const usage = users.map(u => { const counts = {}; tables.forEach(t => { const col = t === 'support_tickets' ? 'user_id' : 'user_id'; counts[t] = db.prepare(`SELECT COUNT(*) AS c FROM ${t} WHERE user_id = ?`).get(u.id).c; }); return { ...u, counts }; }); res.json(usage); }); // --- SUPPORT TICKETS (user-facing) --- router.get('/tickets', requireAuth, (req, res) => { res.json(db.prepare('SELECT id, subject, message, status, created_at FROM support_tickets WHERE user_id = ? ORDER BY created_at DESC').all(req.user.id)); }); router.post('/tickets', requireAuth, (req, res) => { const { subject, message } = req.body; if (!subject || !message) return res.status(400).json({ error: 'Betreff und Nachricht erforderlich' }); if (typeof subject !== 'string' || typeof message !== 'string') return res.status(400).json({ error: 'Ungültige Eingabe' }); const subjectT = subject.trim(), messageT = message.trim(); if (!subjectT || !messageT) return res.status(400).json({ error: 'Felder dürfen nicht leer sein' }); if (subjectT.length > 200) return res.status(400).json({ error: 'Betreff zu lang (max. 200 Zeichen)' }); if (messageT.length > 5000) return res.status(400).json({ error: 'Nachricht zu lang (max. 5000 Zeichen)' }); const result = db.prepare('INSERT INTO support_tickets (user_id, subject, message) VALUES (?, ?, ?)').run(req.user.id, subjectT, messageT); res.json({ id: result.lastInsertRowid }); }); const ticketMsgLimiter = rateLimit({ windowMs: 60 * 1000, max: 15, keyGenerator: (req) => String(req.user.id), message: { error: 'Zu viele Nachrichten. Bitte warten.' }, standardHeaders: true, legacyHeaders: false, validate: { keyGeneratorIpFallback: false }, }); router.get('/tickets/:id/messages', requireAuth, (req, res) => { const ticket = db.prepare('SELECT id, user_id FROM support_tickets WHERE id = ?').get(req.params.id); if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' }); if (req.user.role !== 'admin' && ticket.user_id !== req.user.id) return res.status(403).json({ error: 'Kein Zugriff' }); const messages = db.prepare(` SELECT m.id, m.message, m.created_at, u.username, u.role FROM ticket_messages m JOIN users u ON u.id = m.sender_id WHERE m.ticket_id = ? ORDER BY m.created_at ASC `).all(req.params.id); res.json(messages); }); router.post('/tickets/:id/messages', requireAuth, ticketMsgLimiter, (req, res) => { const { message } = req.body; if (!message || typeof message !== 'string') return res.status(400).json({ error: 'Nachricht erforderlich' }); const trimmed = message.trim(); if (!trimmed) return res.status(400).json({ error: 'Nachricht darf nicht leer sein' }); if (trimmed.length > 5000) return res.status(400).json({ error: 'Nachricht zu lang (max. 5000 Zeichen)' }); const ticket = db.prepare('SELECT id, user_id, status FROM support_tickets WHERE id = ?').get(req.params.id); if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' }); if (req.user.role !== 'admin' && ticket.user_id !== req.user.id) return res.status(403).json({ error: 'Kein Zugriff' }); if (ticket.status === 'closed') return res.status(400).json({ error: 'Ticket ist geschlossen' }); db.prepare('INSERT INTO ticket_messages (ticket_id, sender_id, message) VALUES (?, ?, ?)').run(ticket.id, req.user.id, trimmed); if (req.user.role === 'admin' && ticket.status === 'open') { db.prepare("UPDATE support_tickets SET status = 'in_progress' WHERE id = ?").run(ticket.id); } res.json({ ok: true }); }); // --- CLASS EVENTS (shared, no user filter for GET) --- router.get('/class-events', (req, res) => { res.json(db.prepare('SELECT * FROM class_events ORDER BY date ASC').all()); }); router.post('/class-events', requireAuth, (req, res) => { const { type, title, date, date_end, description } = req.body; if (!type || !title) return res.status(400).json({ error: 'Typ und Titel erforderlich' }); const result = db.prepare('INSERT INTO class_events (type, title, date, date_end, description, created_by) VALUES (?,?,?,?,?,?)') .run(type, title, date||null, date_end||null, description||null, req.user.id); res.json({ id: result.lastInsertRowid }); }); router.delete('/class-events/:id', requireAuth, (req, res) => { db.prepare('DELETE FROM class_events WHERE id = ?').run(req.params.id); res.json({ ok: true }); }); // --- CHAT --- const chatLimiter = rateLimit({ windowMs: 30 * 1000, max: 5, keyGenerator: (req) => String(req.user.id), message: { error: 'Zu viele Nachrichten. Bitte 30 Sekunden warten.' }, standardHeaders: true, legacyHeaders: false, validate: { keyGeneratorIpFallback: false }, }); const CLASS_ID = 'info1'; const CHAT_MAX_LEN = 500; router.get('/chat', requireAuth, (req, res) => { const after = parseInt(req.query.after, 10) || 0; const msgs = after ? db.prepare(` SELECT m.id, m.content, m.created_at, u.username FROM chat_messages m JOIN users u ON u.id = m.user_id WHERE m.class_id = ? AND m.id > ? ORDER BY m.id ASC `).all(CLASS_ID, after) : db.prepare(` SELECT m.id, m.content, m.created_at, u.username FROM chat_messages m JOIN users u ON u.id = m.user_id WHERE m.class_id = ? ORDER BY m.id DESC LIMIT 50 `).all(CLASS_ID).reverse(); res.json(msgs); }); router.post('/chat', requireAuth, chatLimiter, (req, res) => { const { content } = req.body; if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Nachricht erforderlich' }); const trimmed = content.trim(); if (!trimmed) return res.status(400).json({ error: 'Nachricht darf nicht leer sein' }); if (trimmed.length > CHAT_MAX_LEN) return res.status(400).json({ error: `Nachricht zu lang (max. ${CHAT_MAX_LEN} Zeichen)` }); const result = db.prepare('INSERT INTO chat_messages (user_id, class_id, content) VALUES (?, ?, ?)').run(req.user.id, CLASS_ID, trimmed); const msg = db.prepare(` SELECT m.id, m.content, m.created_at, u.username FROM chat_messages m JOIN users u ON u.id = m.user_id WHERE m.id = ? `).get(result.lastInsertRowid); res.json(msg); }); router.delete('/chat/:id', requireAuth, (req, res) => { const msg = db.prepare('SELECT id, user_id FROM chat_messages WHERE id = ?').get(req.params.id); if (!msg) return res.status(404).json({ error: 'Nachricht nicht gefunden' }); if (msg.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Keine Berechtigung' }); db.prepare('DELETE FROM chat_messages WHERE id = ?').run(req.params.id); res.json({ ok: true }); }); // --- PERSONAL CRUD --- function crudRoutes(path, table, fields) { router.get(`/${path}`, requireAuth, (req, res) => { res.json(db.prepare(`SELECT * FROM ${table} WHERE user_id = ?`).all(req.user.id)); }); router.post(`/${path}`, requireAuth, (req, res) => { const vals = fields.map(f => req.body[f] ?? null); const cols = ['user_id', ...fields].join(', '); const placeholders = ['?', ...fields.map(() => '?')].join(', '); const result = db.prepare(`INSERT INTO ${table} (${cols}) VALUES (${placeholders})`).run(req.user.id, ...vals); res.json({ id: result.lastInsertRowid }); }); router.put(`/${path}/:id`, requireAuth, (req, res) => { const sets = fields.map(f => `${f} = ?`).join(', '); const vals = fields.map(f => req.body[f] ?? null); db.prepare(`UPDATE ${table} SET ${sets} WHERE id = ? AND user_id = ?`).run(...vals, req.params.id, req.user.id); res.json({ ok: true }); }); router.delete(`/${path}/:id`, requireAuth, (req, res) => { db.prepare(`DELETE FROM ${table} WHERE id = ? AND user_id = ?`).run(req.params.id, req.user.id); res.json({ ok: true }); }); } crudRoutes('timetable', 'timetable', ['day', 'time_start', 'time_end', 'subject', 'room', 'teacher']); crudRoutes('homework', 'homework', ['subject', 'title', 'due_date', 'done']); crudRoutes('grades', 'grades', ['subject', 'grade', 'type', 'note']); crudRoutes('absences', 'absences', ['date', 'subject', 'reason']); crudRoutes('todos', 'todos', ['title', 'done']); crudRoutes('countdowns', 'countdowns', ['title', 'target_date']); crudRoutes('quicklinks', 'quicklinks', ['label', 'url']); module.exports = router;