clean initial commit
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const SECRET = process.env.JWT_SECRET || 'info1-ifb-secret-change-in-prod';
|
||||
|
||||
function signToken(user) {
|
||||
return jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '30d' });
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, SECRET);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const token = req.cookies?.token;
|
||||
if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||
const user = verifyToken(token);
|
||||
if (!user) return res.status(401).json({ error: 'Ungültige Sitzung' });
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { signToken, verifyToken, requireAuth };
|
||||
@@ -0,0 +1,146 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const db = new Database(path.join(__dirname, '../data.db'));
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS timetable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
time_start TEXT,
|
||||
time_end TEXT,
|
||||
subject TEXT,
|
||||
room TEXT,
|
||||
teacher TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS homework (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subject TEXT,
|
||||
title TEXT NOT NULL,
|
||||
due_date TEXT,
|
||||
done INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
grade REAL,
|
||||
type TEXT DEFAULT 'sonstiges',
|
||||
note TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS absences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT,
|
||||
subject TEXT,
|
||||
reason TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
done INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS countdowns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
target_date TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quicklinks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS class_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT,
|
||||
date_end TEXT,
|
||||
description TEXT,
|
||||
created_by INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
sender_id INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES support_tickets(id),
|
||||
FOREIGN KEY (sender_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_id INTEGER,
|
||||
details TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
class_id TEXT NOT NULL DEFAULT 'info1',
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_name TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Safe migrations
|
||||
try { db.exec(`ALTER TABLE grades ADD COLUMN type TEXT DEFAULT 'sonstiges'`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'student'`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'`); } catch {}
|
||||
|
||||
module.exports = db;
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
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 STORAGE_DIR = path.resolve(__dirname, '../storage');
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
|
||||
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_USER_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
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','svg',
|
||||
'zip','rar','7z','tar','gz',
|
||||
'csv','json','xml',
|
||||
]);
|
||||
|
||||
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',
|
||||
svg: 'image/svg+xml',
|
||||
zip: 'application/zip',
|
||||
rar: 'application/vnd.rar',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
tar: 'application/x-tar',
|
||||
gz: 'application/gzip',
|
||||
csv: 'text/csv',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml',
|
||||
};
|
||||
|
||||
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', 'image/svg+xml',
|
||||
'application/zip', 'application/x-zip-compressed',
|
||||
'application/vnd.rar', 'application/x-rar-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-tar', 'application/gzip', 'application/x-gzip',
|
||||
'text/csv',
|
||||
'application/json', 'text/json',
|
||||
'application/xml', 'text/xml',
|
||||
'application/octet-stream',
|
||||
]);
|
||||
|
||||
const 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 : ''));
|
||||
},
|
||||
});
|
||||
|
||||
function 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' }));
|
||||
}
|
||||
// Empty MIME happens on some Linux/OS configs — extension check is the primary gate
|
||||
if (file.mimetype && !ALLOWED_MIME.has(file.mimetype)) {
|
||||
return cb(Object.assign(new Error('INVALID_MIME'), { code: 'INVALID_MIME' }));
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
|
||||
const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES }, fileFilter });
|
||||
|
||||
function getUserStorageUsed(userId) {
|
||||
return db.prepare('SELECT COALESCE(SUM(size), 0) AS used FROM user_files WHERE user_id = ?').get(userId).used;
|
||||
}
|
||||
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
const files = db.prepare(
|
||||
'SELECT id, original_name, mime_type, size, created_at FROM user_files WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(req.user.id);
|
||||
const used = getUserStorageUsed(req.user.id);
|
||||
res.json({ files, used, quota: MAX_USER_BYTES });
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, (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 used = getUserStorageUsed(req.user.id);
|
||||
if (used + req.file.size > MAX_USER_BYTES) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
return res.status(413).json({ error: 'Speicherplatz voll (max. 2 GB pro Nutzer)' });
|
||||
}
|
||||
|
||||
const ext = path.extname(req.file.originalname).toLowerCase().slice(1);
|
||||
const mime = EXT_MIME[ext] || 'application/octet-stream';
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO user_files (user_id, original_name, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(req.user.id, req.file.originalname, req.file.filename, mime, req.file.size);
|
||||
|
||||
res.json({
|
||||
id: result.lastInsertRowid,
|
||||
original_name: req.file.originalname,
|
||||
mime_type: mime,
|
||||
size: req.file.size,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:id/download', requireAuth, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
||||
const file = db.prepare('SELECT * FROM user_files WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
const filePath = path.join(STORAGE_DIR, file.stored_name);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
res.setHeader('Content-Type', file.mime_type);
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.original_name)}`);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'private, no-cache');
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAuth, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
||||
const file = db.prepare('SELECT * FROM user_files WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
fs.unlink(path.join(STORAGE_DIR, file.stored_name), () => {});
|
||||
db.prepare('DELETE FROM user_files WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
function deleteUserFiles(userId) {
|
||||
const files = db.prepare('SELECT stored_name FROM user_files WHERE user_id = ?').all(userId);
|
||||
files.forEach(f => fs.unlink(path.join(STORAGE_DIR, f.stored_name), () => {}));
|
||||
db.prepare('DELETE FROM user_files WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
module.exports = { router, deleteUserFiles };
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user