diff --git a/.gamification.json b/.gamification.json index f4ef2b6..3db7cb3 100644 --- a/.gamification.json +++ b/.gamification.json @@ -10,5 +10,5 @@ "streak_tage": 1, "letzter_aktiver_tag": "2026-08-29", "historie": [], - "zuletzt_aktualisiert": "2026-08-29T20:50:51.073181" + "zuletzt_aktualisiert": "2026-08-29T20:57:49.286070" } \ No newline at end of file diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..40ae070 --- /dev/null +++ b/.htaccess @@ -0,0 +1,20 @@ +# 🔒 APACHE / WEBSPACE SICHERHEITSKONFIGURATION + +# 1. Blockiere Zugriff auf SQLite-Datenbanken und sensible Dateien + + Order Allow,Deny + Deny from all + + +# 2. Blockiere versteckte Dateien (.git, .env, etc.) + + Order Allow,Deny + Deny from all + + +# 3. Security Header + + Header set X-Content-Type-Options "nosniff" + Header set X-Frame-Options "SAMEORIGIN" + Header set Referrer-Policy "strict-origin-when-cross-origin" + diff --git a/api.php b/api.php index 4df2130..76d8031 100644 --- a/api.php +++ b/api.php @@ -1,11 +1,19 @@ setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +// 1. SECRET KEY LADEN +$secret_key = getenv('SECRET_KEY'); +if (!$secret_key || strlen($secret_key) < 32) { + $secret_file = __DIR__ . '/.secret_key'; + if (file_exists($secret_file)) { + $secret_key = trim((string)file_get_contents($secret_file)); + } else { + $secret_key = bin2hex(random_bytes(32)); + @file_put_contents($secret_file, $secret_key, LOCK_EX); + @chmod($secret_file, 0600); + } +} -// Init Tabellen +// 2. DATENBANK VERBINDUNG +$db_file = __DIR__ . '/platform_data.db'; +try { + $pdo = new PDO('sqlite:' . $db_file); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['error' => 'Datenbankverbindung fehlgeschlagen']); + exit; +} + +// 3. TABELLEN INITIALISIERUNG $pdo->exec(" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -60,19 +89,35 @@ CREATE TABLE IF NOT EXISTS class_enrollments ( ); "); -$path = $_SERVER['PATH_INFO'] ?? $_GET['action'] ?? ''; -$body = json_decode(file_get_contents('php://input'), true) ?? []; +// 4. AUTH HELPER MIT SICHERER HMAC-VERIFIKATION +function get_auth_user(string $secret_key): ?array { + $auth = ''; + if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + $auth = $_SERVER['HTTP_AUTHORIZATION']; + } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + $auth = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } elseif (function_exists('getallheaders')) { + $headers = getallheaders(); + $auth = $headers['Authorization'] ?? $headers['authorization'] ?? ''; + } -// Helper Auth -function get_auth_user($pdo) { - $headers = getallheaders(); - $auth = $headers['Authorization'] ?? $headers['authorization'] ?? ''; if (strpos($auth, 'Bearer ') === 0) { $token = substr($auth, 7); $parts = explode('.', $token); if (count($parts) === 2) { - $payload = json_decode(base64_decode($parts[0]), true); - if ($payload && isset($payload['uid'])) { + $payload_raw = base64_decode($parts[0], true); + if ($payload_raw === false) return null; + + $expected_sig = hash_hmac('sha256', $parts[0], $secret_key); + if (!hash_equals($expected_sig, $parts[1])) { + return null; // Ungültige Signatur + } + + $payload = json_decode($payload_raw, true); + if ($payload && isset($payload['uid']) && isset($payload['exp'])) { + if ($payload['exp'] < time()) { + return null; // Abgelaufen + } return $payload; } } @@ -80,22 +125,32 @@ function get_auth_user($pdo) { return null; } -function create_token($uid, $email, $role) { - $payload = base64_encode(json_encode(['uid' => $uid, 'email' => $email, 'role' => $role])); - $sig = hash_hmac('sha256', $payload, 'python_lernportal_secret'); +function create_token(int $uid, string $email, string $role, string $secret_key): string { + $payload_data = [ + 'uid' => $uid, + 'email' => $email, + 'role' => $role, + 'exp' => time() + (86400 * 30) + ]; + $payload = base64_encode((string)json_encode($payload_data)); + $sig = hash_hmac('sha256', $payload, $secret_key); return $payload . '.' . $sig; } +$path = $_SERVER['PATH_INFO'] ?? $_GET['action'] ?? ''; +$raw_input = file_get_contents('php://input'); +$body = $raw_input ? (json_decode($raw_input, true) ?? []) : []; + // 1. REGISTER if ($path === '/auth/register') { - $email = strtolower(trim($body['email'] ?? '')); - $password = $body['password'] ?? ''; - $name = trim($body['name'] ?? ''); - $role = $body['role'] ?? 'student'; + $email = strtolower(trim((string)($body['email'] ?? ''))); + $password = (string)($body['password'] ?? ''); + $name = trim((string)($body['name'] ?? '')); + $role = ($body['role'] ?? 'student') === 'teacher' ? 'teacher' : 'student'; - if (!$email || !$password || !$name) { + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 6 || strlen($name) < 1) { http_response_code(400); - echo json_encode(['error' => 'Pflichtfelder fehlen']); + echo json_encode(['error' => 'Ungültige Eingabedaten']); exit; } @@ -105,34 +160,34 @@ if ($path === '/auth/register') { try { $stmt = $pdo->prepare("INSERT INTO users (email, password_hash, salt, name, role) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$email, $hash, $salt, $name, $role]); - $uid = $pdo->lastInsertId(); - $token = create_token($uid, $email, $role); + $uid = (int)$pdo->lastInsertId(); + $token = create_token($uid, $email, $role, $secret_key); http_response_code(201); echo json_encode(['token' => $token, 'user' => ['id' => $uid, 'email' => $email, 'name' => $name, 'role' => $role, 'xp' => 0, 'level' => 1]]); - } catch (Exception $e) { + } catch (PDOException $e) { http_response_code(409); echo json_encode(['error' => 'E-Mail existiert bereits']); } exit; } -// 2. LOGIN +// 2. LOGIN (mit Constant-Time Dummy PBKDF2) if ($path === '/auth/login') { - $email = strtolower(trim($body['email'] ?? '')); - $password = $body['password'] ?? ''; + $email = strtolower(trim((string)($body['email'] ?? ''))); + $password = (string)($body['password'] ?? ''); $stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?"); $stmt->execute([$email]); - $user = $stmt->fetch(PDO::FETCH_ASSOC); + $user = $stmt->fetch(); if ($user) { $hash = hash_pbkdf2('sha256', $password, $user['salt'], 100000); if (hash_equals($hash, $user['password_hash'])) { - $token = create_token($user['id'], $user['email'], $user['role']); + $token = create_token((int)$user['id'], $user['email'], $user['role'], $secret_key); echo json_encode([ 'token' => $token, 'user' => [ - 'id' => $user['id'], + 'id' => (int)$user['id'], 'email' => $user['email'], 'name' => $user['name'], 'role' => $user['role'], @@ -142,7 +197,10 @@ if ($path === '/auth/login') { ]); exit; } + } else { + hash_pbkdf2('sha256', $password, 'dummy_salt_fixed', 100000); } + http_response_code(401); echo json_encode(['error' => 'Ungültige Anmeldedaten']); exit; @@ -150,11 +208,11 @@ if ($path === '/auth/login') { // 3. PROGRESS SAVE if ($path === '/progress/save') { - $user = get_auth_user($pdo); + $user = get_auth_user($secret_key); if (!$user) { http_response_code(401); echo json_encode(['error' => 'Nicht angemeldet']); exit; } - $chapter_id = $body['chapter_id'] ?? ''; - $code_draft = $body['code_draft'] ?? ''; + $chapter_id = substr(trim((string)($body['chapter_id'] ?? '')), 0, 120); + $code_draft = substr((string)($body['code_draft'] ?? ''), 0, 100000); $stmt = $pdo->prepare("INSERT INTO chapter_progress (user_id, chapter_id, code_draft) VALUES (?, ?, ?) ON CONFLICT(user_id, chapter_id) DO UPDATE SET code_draft = excluded.code_draft, updated_at = CURRENT_TIMESTAMP"); $stmt->execute([$user['uid'], $chapter_id, $code_draft]); @@ -162,13 +220,13 @@ if ($path === '/progress/save') { exit; } -// 4. PROGRESS SOLVE +// 4. PROGRESS SOLVE (mit Anti-Cheat Cap) if ($path === '/progress/solve') { - $user = get_auth_user($pdo); + $user = get_auth_user($secret_key); if (!$user) { http_response_code(401); echo json_encode(['error' => 'Nicht angemeldet']); exit; } - $chapter_id = $body['chapter_id'] ?? ''; - $xp_reward = (int)($body['xp_reward'] ?? 100); + $chapter_id = substr(trim((string)($body['chapter_id'] ?? '')), 0, 120); + $xp_reward = min(max(0, (int)($body['xp_reward'] ?? 100)), 100); $stmt = $pdo->prepare("INSERT INTO chapter_progress (user_id, chapter_id, is_solved, solved_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP) ON CONFLICT(user_id, chapter_id) DO UPDATE SET is_solved = 1, solved_at = CURRENT_TIMESTAMP"); $stmt->execute([$user['uid'], $chapter_id]); diff --git a/assets/certificate.js b/assets/certificate.js index f8da915..1d03f03 100644 --- a/assets/certificate.js +++ b/assets/certificate.js @@ -5,22 +5,54 @@ */ (function () { - window.zeigeZertifikatModal = function (lehrpfadNummer) { - const data = window.GAMIFICATION_DATA || { spieler_name: "Python-Entwicklerin", level: 1 }; + function escapeHtml(text) { + if (!text) return ""; + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + window.zeigeZertifikatModal = async function (lehrpfadNummer) { + const data = window.GAMIFICATION_DATA || { spieler_name: "Python-Entwickler", level: 1 }; + const user = window.AUTH ? window.AUTH.user : null; + const studentName = (user && user.name) ? user.name : (data.spieler_name || "Python-Entwickler"); const dateStr = new Date().toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" }); let titel = "Grundlagen der Python-Programmierung"; let moduleDesc = "10 Module: Zahlen & Rechnen, Datentypen, f-Strings, Kontrollstrukturen, Funktionen, Listen, Strings, Dictionaries, Sets & Comprehensions"; - - if (lehrpfadNummer === 2) { + let trackKey = "lehrpfad_1"; + + if (lehrpfadNummer === 2 || lehrpfadNummer === "lehrpfad_2") { titel = "Einstieg in die Objektorientierung (OOP) & Entwickler-Tools"; moduleDesc = "9 Module: Klassen, Konstruktor __init__, self, Methoden, UML-Diagramme, VS Code, Git-Versionskontrolle, Komposition & Tamagotchi-Projekt"; - } else if (lehrpfadNummer === 3) { + trackKey = "lehrpfad_2"; + } else if (lehrpfadNummer === 3 || lehrpfadNummer === "lehrpfad_3") { titel = "Fortgeschrittenes OOP, Softwarequalität & Desktop-GUIs"; moduleDesc = "10 Module: Speicher-Referenzen, Dunder-Methoden, TDD Unit Testing, Vererbung, Polymorphie, Exceptions, JSON-Persistenz, Tkinter GUIs, Container & Masterprojekt"; + trackKey = "lehrpfad_3"; } else if (lehrpfadNummer === "master") { titel = "Master of Python Software Engineering"; moduleDesc = "Vollständiger Abschluss aller 3 Lehrpfade (27 Module, 188 bestandene automatisierte Unittests, Model-View-Controller Desktop-App)"; + trackKey = "master"; + } + + let certUuid = "CERT-" + Math.random().toString(36).substring(2, 10).toUpperCase(); + + // Registriere Zertifikat im Backend falls angemeldet + const token = localStorage.getItem("auth_token"); + if (token) { + try { + const res = await fetch("/api/certificates/create", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ track_id: trackKey, student_name: studentName }) + }); + const resData = await res.json(); + if (resData.uuid) certUuid = resData.uuid; + } catch (e) {} } const modal = document.createElement("div"); @@ -32,18 +64,21 @@
🎓 OFFIZIELLES ZERTIFIKAT 🎓

Erfolgreicher Abschluss

Hiermit wird bescheinigt, dass

-
${data.spieler_name || "Python-Entwicklerin"}
+
${escapeHtml(studentName)}

den anspruchsvollen Lehrpfad

-

${titel}

-

${moduleDesc}

+

${escapeHtml(titel)}

+

${escapeHtml(moduleDesc)}

- Erreichtes Level: Level ${data.level || 1} (${data.titel || "Entwicklerin"}) + Erreichtes Level: Level ${data.level || 1} (${escapeHtml(data.titel || "Entwickler")})
Datum: ${dateStr}
+
+ Zertifikats-ID: ${escapeHtml(certUuid)} +
🏆 EXZELLENZ-SIEGEL
@@ -56,4 +91,7 @@ document.body.appendChild(modal); }; + + // Alias für index.html Buttons + window.openCertificate = window.zeigeZertifikatModal; })(); diff --git a/assets/gamification.js b/assets/gamification.js index f45ed3c..7204188 100644 --- a/assets/gamification.js +++ b/assets/gamification.js @@ -5,38 +5,36 @@ */ (function () { - // Standard-Kataloge als Fallback, falls gamification_data.js noch nicht existiert const DEFAULT_LEVELS = [ { level: 1, min_xp: 0, max_xp: 150, titel: "Code-Küken 🐣", rang: "Bronze I" }, - { level: 2, min_xp: 151, max_xp: 400, titel: "Code-Detektivin 🔍", rang: "Bronze II" }, - { level: 3, min_xp: 401, max_xp: 800, titel: "Logik-Bastlerin ⚙️", rang: "Silber I" }, - { level: 4, min_xp: 801, max_xp: 1300, titel: "Klassen-Baumeisterin 🏗️", rang: "Silber II" }, - { level: 5, min_xp: 1301, max_xp: 2000, titel: "Git-Strategin 🌿", rang: "Gold I" }, - { level: 6, min_xp: 2001, max_xp: 2900, titel: "TDD-Qualitätsprüferin 🧪", rang: "Gold II" }, - { level: 7, min_xp: 2901, max_xp: 4000, titel: "Vererbungs-Meisterin 🧬", rang: "Platin I" }, - { level: 8, min_xp: 4001, max_xp: 5300, titel: "Exception-Wächterin 🛡️", rang: "Platin II" }, - { level: 9, min_xp: 5301, max_xp: 6800, titel: "GUI-Entwicklerin 🖥️", rang: "Diamant" }, - { level: 10, min_xp: 6801, max_xp: 99999, titel: "Software-Architektin 🏆", rang: "Großmeisterin" } + { level: 2, min_xp: 151, max_xp: 400, titel: "Code-Detektiv 🔍", rang: "Bronze II" }, + { level: 3, min_xp: 401, max_xp: 800, titel: "Logik-Bastler ⚙️", rang: "Silber I" }, + { level: 4, min_xp: 801, max_xp: 1300, titel: "Klassen-Baumeister 🏗️", rang: "Silber II" }, + { level: 5, min_xp: 1301, max_xp: 2000, titel: "Git-Stratege 🌿", rang: "Gold I" }, + { level: 6, min_xp: 2001, max_xp: 2900, titel: "TDD-Qualitätsprüfer 🧪", rang: "Gold II" }, + { level: 7, min_xp: 2901, max_xp: 4000, titel: "Vererbungs-Meister 🧬", rang: "Platin I" }, + { level: 8, min_xp: 4001, max_xp: 5300, titel: "Exception-Wächter 🛡️", rang: "Platin II" }, + { level: 9, min_xp: 5301, max_xp: 6800, titel: "GUI-Entwickler 🖥️", rang: "Diamant" }, + { level: 10, min_xp: 6801, max_xp: 99999, titel: "Software-Architekt 🏆", rang: "Großmeister" } ]; const DEFAULT_BADGES = { - rechenkonegin: { icon: "🧮", name: "Rechenkönigin", desc: "Grundlagen G01 & G02 abgeschlossen (Zahlen, Operatoren & Typen gemeistert)." }, + rechenkonegin: { icon: "🧮", name: "Rechenkönig", desc: "Grundlagen G01 & G02 abgeschlossen (Zahlen, Operatoren & Typen gemeistert)." }, dialog_profi: { icon: "💬", name: "Dialog-Profi", desc: "Grundlagen G03 abgeschlossen (Interaktive Ein-/Ausgabe mit f-Strings)." }, - weichenstellerin: { icon: "🚦", name: "Weichenstellerin", desc: "Grundlagen G04 abgeschlossen (Bedingungslogik & Schaltjahre gemeistert)." }, - schleifen_dompteurin: { icon: "🎡", name: "Schleifen-Dompteurin", desc: "Grundlagen G05 abgeschlossen (While- & For-Schleifen gebändigt)." }, - funktions_zauberin: { icon: "🪄", name: "Funktions-Zauberin", desc: "Grundlagen G06 abgeschlossen (Eigene Funktionen & Module gebaut)." }, - listen_archivarin: { icon: "📋", name: "Listen-Archivarin", desc: "Grundlagen G07 & G08 abgeschlossen (Sequenzen & Strings analysiert)." }, - daten_strategin: { icon: "🗃️", name: "Daten-Strategin", desc: "Grundlagen G09 & G10 abgeschlossen (Dicts, Sets & Comprehensions gemeistert)." }, - bug_jaegerin: { icon: "🔍", name: "Bug-Jägerin", desc: "Kapitel 00 gelöst (Alle Fehler-Bugs im Warm-up aufgespürt)." }, - erste_architektin: { icon: "🏗️", name: "Erste Architektin", desc: "Kapitel 01 bis 03 gelöst (Erste OOP-Klassen, Konstruktoren & Methoden)." }, - zeit_reisende: { icon: "🌿", name: "Git-Zeitreisende", desc: "Kapitel 04c Git-Versionskontrolle verstanden & Spielstände gesichert." }, - tamagotchi_mama: { icon: "🥚", name: "Tamagotchi-Mama", desc: "Kapitel 06 Mini-Projekt abgeschlossen (Ein lebendiges Haustier gebaut)." }, - qualitaets_garantin: { icon: "🧪", name: "TDD-Qualitätsgarantin", desc: "Kapitel 09 abgeschlossen (Eigene professionelle Unit Tests geschrieben)." }, + weichenstellerin: { icon: "🚦", name: "Weichensteller", desc: "Grundlagen G04 abgeschlossen (Bedingungslogik & Schaltjahre gemeistert)." }, + schleifen_dompteurin: { icon: "🎡", name: "Schleifen-Dompteur", desc: "Grundlagen G05 abgeschlossen (While- & For-Schleifen gebändigt)." }, + funktions_zauberin: { icon: "🪄", name: "Funktions-Zauberer", desc: "Grundlagen G06 abgeschlossen (Eigene Funktionen & Module gebaut)." }, + listen_archivarin: { icon: "📋", name: "Listen-Archivar", desc: "Grundlagen G07 & G08 abgeschlossen (Sequenzen & Strings analysiert)." }, + daten_strategin: { icon: "🗃️", name: "Daten-Stratege", desc: "Grundlagen G09 & G10 abgeschlossen (Dicts, Sets & Comprehensions gemeistert)." }, + bug_jaegerin: { icon: "🔍", name: "Bug-Jäger", desc: "Kapitel 00 gelöst (Alle Fehler-Bugs im Warm-up aufgespürt)." }, + erste_architektin: { icon: "🏗️", name: "Erster Architekt", desc: "Kapitel 01 bis 03 gelöst (Erste OOP-Klassen, Konstruktoren & Methoden)." }, + zeit_reisende: { icon: "🌿", name: "Git-Zeitreisender", desc: "Kapitel 04c Git-Versionskontrolle verstanden & Spielstände gesichert." }, + tamagotchi_mama: { icon: "🥚", name: "Tamagotchi-Hüter", desc: "Kapitel 06 Mini-Projekt abgeschlossen (Ein lebendiges Haustier gebaut)." }, + qualitaets_garantin: { icon: "🧪", name: "TDD-Qualitätsgarant", desc: "Kapitel 09 abgeschlossen (Eigene professionelle Unit Tests geschrieben)." }, oop_grossmeisterin: { icon: "🧬", name: "Vererbungs-Koryphäe", desc: "Kapitel 10 bis 12 abgeschlossen (Vererbung, Polymorphie & Exceptions)." }, - software_architektin: { icon: "🏆", name: "Meister-Entwicklerin", desc: "Kapitel 16 Master-Projekt abgeschlossen & vollwertige Desktop-App gebaut!" } + software_architektin: { icon: "🏆", name: "Meister-Entwickler", desc: "Kapitel 16 Master-Projekt abgeschlossen & vollwertige Desktop-App gebaut!" } }; - // Daten abrufen function getGamificationData() { if (window.GAMIFICATION_DATA) { return window.GAMIFICATION_DATA; @@ -46,7 +44,7 @@ try { return JSON.parse(saved); } catch (e) { } } return { - spieler_name: "Python-Entwicklerin", + spieler_name: "Python-Entwickler", xp: 0, level: 1, titel: "Code-Küken 🐣", @@ -58,120 +56,89 @@ }; } - function getLevelInfo(xp) { - const levels = window.GAMIFICATION_LEVEL_STUFEN || DEFAULT_LEVELS; - for (let info of levels) { - if (xp >= info.min_xp && xp <= info.max_xp) { - const nextXp = info.max_xp + 1; - const xpInLevel = xp - info.min_xp; - const xpForLevel = nextXp - info.min_xp; - const prozent = xpForLevel > 0 ? Math.min(100, Math.round((xpInLevel / xpForLevel) * 100)) : 100; - return { ...info, naechste_stufe_xp: nextXp, prozent: prozent }; + function saveGamificationData(data) { + window.GAMIFICATION_DATA = data; + localStorage.setItem("python_gamification_state", JSON.stringify(data)); + } + + // Globaler XP-Zuwachs & Levelberechnung + window.addXP = function (amount, chapterId = "") { + const data = getGamificationData(); + data.xp = (data.xp || 0) + amount; + + if (chapterId && !data.geloeste_kapitel.includes(chapterId)) { + data.geloeste_kapitel.push(chapterId); + } + + // Level prüfen + for (let i = DEFAULT_LEVELS.length - 1; i >= 0; i--) { + if (data.xp >= DEFAULT_LEVELS[i].min_xp) { + data.level = DEFAULT_LEVELS[i].level; + data.titel = DEFAULT_LEVELS[i].titel; + data.rang = DEFAULT_LEVELS[i].rang; + break; } } - return { level: 10, titel: "Software-Architektin 🏆", rang: "Großmeisterin", naechste_stufe_xp: 99999, prozent: 100 }; - } - // HUD & Trophäenraum rendern - function renderGamificationHUD() { + saveGamificationData(data); + window.renderGamificationHUD(); + return data; + }; + + window.renderGamificationHUD = function () { const data = getGamificationData(); - const lvlInfo = getLevelInfo(data.xp || 0); - const badgesKatalog = window.GAMIFICATION_BADGES_KATALOG || DEFAULT_BADGES; - const unlockedBadges = new Set(data.freigeschaltete_badges || []); - const badgeCount = unlockedBadges.size; - const totalBadges = Object.keys(badgesKatalog).length; + const levels = (window.GAMIFICATION_DATA && window.GAMIFICATION_DATA.levels) ? window.GAMIFICATION_DATA.levels : DEFAULT_LEVELS; + const badges = (window.GAMIFICATION_DATA && window.GAMIFICATION_DATA.badges) ? window.GAMIFICATION_DATA.badges : DEFAULT_BADGES; - // Finde oder erstelle den HUD-Container - let hud = document.getElementById("gamer-hud"); - if (!hud) { - const mainContainer = document.querySelector(".container") || document.body; - hud = document.createElement("div"); - hud.id = "gamer-hud"; - mainContainer.prepend(hud); + const currentLevelObj = levels.find((l) => l.level === data.level) || levels[0]; + const nextLevelObj = levels.find((l) => l.level === data.level + 1); + + let progressPercent = 100; + let xpNeeded = "Max Level"; + if (nextLevelObj) { + const xpInCurrentLevel = data.xp - currentLevelObj.min_xp; + const levelSpan = nextLevelObj.min_xp - currentLevelObj.min_xp; + progressPercent = Math.min(100, Math.max(0, Math.round((xpInCurrentLevel / levelSpan) * 100))); + xpNeeded = `${data.xp} / ${nextLevelObj.min_xp} XP`; } - hud.innerHTML = ` -
-
-
-
🎮
-
-
${data.spieler_name || "Python-Entwicklerin"}
-
${lvlInfo.titel} • ${lvlInfo.rang}
-
-
-
-
- ⭐ Level ${lvlInfo.level} - ${data.xp || 0} XP -
-
- 🔥 ${data.streak_tage || 1} Tage - Lern-Streak -
-
- 🏆 ${badgeCount}/${totalBadges} - Trophäen -
-
-
+ // Update Topbar / Sidebar Indicators + const sbLevel = document.getElementById("sidebar-level-text"); + const sbXP = document.getElementById("sidebar-xp-text"); + if (sbLevel) sbLevel.innerText = `⭐ Level ${data.level}`; + if (sbXP) sbXP.innerText = `${data.xp} XP`; -
-
- Fortschritt zu Level ${lvlInfo.level + 1} - ${data.xp || 0} / ${lvlInfo.naechste_stufe_xp} XP (${lvlInfo.prozent}%) -
-
-
-
-
+ // Update HUD Widget if present + const hud = document.getElementById("gamification-hud"); + if (!hud) return; - + const avatarIcon = currentLevelObj.titel.split(" ").pop() || "🐣"; + const solvedCount = (data.geloeste_kapitel || []).length; + const totalChapters = 27; - -
- `; + const nameEl = document.getElementById("hud-user-name"); + const titleEl = document.getElementById("hud-user-title"); + const rankEl = document.getElementById("hud-user-rank"); + const levelEl = document.getElementById("hud-stat-level"); + const xpEl = document.getElementById("hud-stat-xp"); + const solvedEl = document.getElementById("hud-stat-solved"); + const streakEl = document.getElementById("hud-stat-streak"); + const barEl = document.getElementById("hud-progress-bar"); + const progressTextEl = document.getElementById("hud-progress-text"); + const avatarEl = document.getElementById("hud-avatar-icon"); - // Event Listener für Trophäenraum Toggle - const toggleBtn = document.getElementById("toggle-trophies-btn"); - const trophyRoom = document.getElementById("trophy-room"); - if (toggleBtn && trophyRoom) { - toggleBtn.addEventListener("click", function () { - const isHidden = trophyRoom.style.display === "none"; - trophyRoom.style.display = isHidden ? "block" : "none"; - toggleBtn.innerText = isHidden - ? `🔼 Trophäenraum einklappen (${badgeCount}/${totalBadges})` - : `🏆 Trophäenraum öffnen (${badgeCount}/${totalBadges} freigeschaltet)`; - }); - } - } + if (nameEl) nameEl.innerText = data.spieler_name || "Entwickler-Pass"; + if (titleEl) titleEl.innerText = currentLevelObj.titel; + if (rankEl) rankEl.innerText = `Rang: ${currentLevelObj.rang}`; + if (levelEl) levelEl.innerText = data.level; + if (xpEl) xpEl.innerText = `${data.xp} XP`; + if (solvedEl) solvedEl.innerText = `${solvedCount} / ${totalChapters}`; + if (streakEl) streakEl.innerText = `🔥 ${data.streak_tage || 1} Tag${(data.streak_tage || 1) > 1 ? "e" : ""}`; + if (barEl) barEl.style.width = `${progressPercent}%`; + if (progressTextEl) progressTextEl.innerText = xpNeeded; + if (avatarEl) avatarEl.innerText = avatarIcon; + }; - // Checkbox-Persistenz für alle Aufgaben-Seiten (localStorage) function initTaskCheckboxes() { const checkboxes = document.querySelectorAll(".task-checkbox"); if (!checkboxes.length) return; @@ -192,14 +159,13 @@ }); } - // Initialisierung beim Laden if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { - renderGamificationHUD(); + window.renderGamificationHUD(); initTaskCheckboxes(); }); } else { - renderGamificationHUD(); + window.renderGamificationHUD(); initTaskCheckboxes(); } })(); diff --git a/assets/gamification_data.js b/assets/gamification_data.js index c2a1cbb..b117c02 100644 --- a/assets/gamification_data.js +++ b/assets/gamification_data.js @@ -11,7 +11,7 @@ window.GAMIFICATION_DATA = { "streak_tage": 1, "letzter_aktiver_tag": "2026-08-29", "historie": [], - "zuletzt_aktualisiert": "2026-08-29T20:50:51.073181" + "zuletzt_aktualisiert": "2026-08-29T20:57:49.286070" }; window.GAMIFICATION_BADGES_KATALOG = { "rechenkonegin": { diff --git a/assets/interpreter.js b/assets/interpreter.js index 6eb7323..52e42c4 100644 --- a/assets/interpreter.js +++ b/assets/interpreter.js @@ -8,6 +8,18 @@ (function () { const ERROR_RULES = [ + { + type: "SyntaxError", + pattern: /SyntaxError: (invalid syntax|expected ':'|was never closed|cannot assign to expression)/i, + title: "✍️ Schreibweise- & Grammatikfehler (SyntaxError)", + explanation: "Python versteht den Aufbau deines Codes an dieser Stelle nicht. Meist fehlt ein Satzzeichen oder eine Klammer.", + causes: [ + "Fehlender Doppelpunkt : am Ende von def, if, elif, else, for oder while.", + "Eine geöffnete Klammer ( oder ein Anführungszeichen \" wurde nicht geschlossen.", + "Verwechslung von Zuweisung = und Vergleich == in einer if-Bedingung." + ], + fix: "Prüfe die Zeile ganz genau: Steht am Zeilenende ein Doppelpunkt? Sind alle Klammern und Anführungszeichen paarweise geschlossen? Nutze if a == b: zum Vergleichen!" + }, { type: "IndentationError", pattern: /IndentationError: (expected an indented block|unindent does not match)/i, @@ -19,6 +31,16 @@ ], fix: "Drücke nach dem Doppelpunkt Enter und rücke die Zeile mit Tab oder 4 Leerzeichen ein. Mit Umschalt + Tab kannst du Zeilen wieder nach links rücken." }, + { + type: "TypeError String Concatenation", + pattern: /TypeError: can only concatenate str \(not "(int|float)"\) to str/i, + title: "🔤 Text und Zahl vermischt (TypeError)", + explanation: "Du versuchst einen Text (str) und eine Zahl (int/float) mit + zusammenzufügen.", + causes: [ + "In Python kann man Text und Zahlen nicht einfach addieren (z.B. \"Alter: \" + 16 schlägt fehl)." + ], + fix: "Nutze einen modernen f-String: f\"Alter: {alter}\" oder wandle die Zahl mit str(zahl) um." + }, { type: "TypeError NoneType", pattern: /TypeError: 'NoneType' object is not (subscriptable|iterable|callable)/i, @@ -85,6 +107,29 @@ ], fix: "Prüfe, ob du die Variable vor ihrer Benutzung angelegt hast und ob alle benötigten Module oben mit import ... eingebunden sind." }, + { + type: "ValueError", + pattern: /ValueError: (invalid literal for int\(\)|math domain error)/i, + title: "⚠️ Ungültiger Wert (ValueError)", + explanation: "Der Datentyp war zwar richtig, aber der übergebene Wert macht für diesen Befehl keinen Sinn.", + causes: [ + "Umwandlung von Text mit Buchstaben in eine Zahl (z.B. int(\"hallo\")).", + "Mathematisch unzulässige Werte (z.B. Wurzel aus einer negativen Zahl)." + ], + fix: "Prüfe vorher mit text.isdigit(), ob der Text nur aus Ziffern besteht, oder sichere die Umwandlung mit try-except ValueError: ab." + }, + { + type: "AssertionError", + pattern: /AssertionError: (.+)/i, + title: "🧪 Unittest fehlgeschlagen (AssertionError)", + explanation: "Dein Programmcode läuft zwar ohne Absturz durch, liefert aber noch nicht das von der Aufgabe erwartete Ergebnis.", + causes: [ + "Ein Rechenfehler oder eine falsche Formel in der Funktion.", + "Rückgabewert vergessen (return fehlt).", + "Grenzfall nicht beachtet (z.B. 0 oder leere Liste)." + ], + fix: "Vergleiche die Rückgabe deiner Funktion mit dem in der Testmeldung geforderten Soll-Wert. Lies die Aufgabenbeschreibung noch einmal aufmerksam durch!" + }, { type: "ZeroDivisionError", pattern: /ZeroDivisionError: division by zero/i, @@ -101,9 +146,12 @@ if (!errorText || !errorText.trim()) { return { matched: false, + name: "💡 Bitte füge eine Fehlermeldung ein", title: "💡 Bitte füge eine Fehlermeldung ein", explanation: "Kopiere deinen Traceback aus dem Terminal und füge ihn hier ein.", - fix: "Starte dein Skript im Terminal mit python3 test_aufgabe.py" + causes: ["Noch keine Fehlermeldung eingegeben."], + fixes: ["Starte deinen Code oder Unittests, um eventuelle Fehler zu sehen."], + fix: "Starte deinen Code oder Unittests, um eventuelle Fehler zu sehen." }; } @@ -112,9 +160,11 @@ return { matched: true, type: rule.type, + name: rule.title, title: rule.title, explanation: rule.explanation, causes: rule.causes, + fixes: [rule.fix], fix: rule.fix }; } @@ -122,49 +172,40 @@ return { matched: false, + name: "🔍 Allgemeiner Python-Fehler", title: "🔍 Allgemeiner Python-Fehler", explanation: "Lies den Traceback immer von ganz unten nach oben: In der allerletzten Zeile nennt Python die genaue Fehlerart und Zeilennummer.", causes: ["Syntax- oder Logikfehler in deinem Code."], + fixes: ["Überprüfe die in der letzten Zeile genannte Zeilennummer in deiner aufgabe.py Datei."], fix: "Überprüfe die in der letzten Zeile genannte Zeilennummer in deiner aufgabe.py Datei." }; }; + // Exportiertes Objekt für Workspace & Widgets + window.INTERPRETER = { + translate: window.interpretiereTraceback + }; + // Widget rendern, falls Container vorhanden document.addEventListener("DOMContentLoaded", () => { const interpreterEl = document.getElementById("error-interpreter-widget"); if (!interpreterEl) return; - interpreterEl.innerHTML = ` -
-

🩺 Interaktiver Python-Fehler-Dolmetscher

-

Du verstehst eine Fehlermeldung aus dem Terminal nicht? Füge sie hier ein und erhalte sofort eine schülerfreundliche deutsche Erklärung mit Reparatur-Anleitung!

- - - - - - -
- `; + const input = document.getElementById("error-input"); + const btn = document.getElementById("btn-interpret-error"); + const result = document.getElementById("error-result"); - const input = document.getElementById("traceback-input"); - const btn = document.getElementById("btn-interpret"); - const result = document.getElementById("interpreter-result"); - - btn.addEventListener("click", () => { - const res = window.interpretiereTraceback(input.value); - result.innerHTML = ` -
-
${res.title}
-

Was bedeutet das? ${res.explanation}

- ${res.causes ? `

Mögliche Ursachen:

` : ''} -
- 🔧 So behebst du den Fehler: -

${res.fix}

-
-
- `; - result.style.display = "block"; - }); + if (btn && input && result) { + btn.addEventListener("click", () => { + const res = window.interpretiereTraceback(input.value); + document.getElementById("error-name").innerText = res.title; + document.getElementById("error-explanation").innerHTML = res.explanation; + const fixList = document.getElementById("error-fixes"); + if (fixList) { + fixList.innerHTML = (res.causes || []).map(c => `
  • ${c}
  • `).join("") + `
  • Lösung: ${res.fix}
  • `; + } + result.style.display = "block"; + }); + } }); })(); diff --git a/assets/monaco_ide.js b/assets/monaco_ide.js index 50e529e..92fc300 100644 --- a/assets/monaco_ide.js +++ b/assets/monaco_ide.js @@ -1,11 +1,12 @@ /** - * ⚡ MONACO WEB-IDE & PYODIDE TEST-RUNNER ENGINE ⚡ - * ================================================= - * Interaktive Split-Screen Entwicklungsumgebung mit: + * ⚡ GEHÄRTETE MONACO WEB-IDE & PYODIDE RUNNER ENGINE ⚡ + * ===================================================== + * Interaktive Entwicklungsumgebung mit: * - Monaco Editor (VS Code Engine mit Autocomplete & Syntax-Highlighting) + * - Tastatur-Shortcuts: Strg+Enter (Run), Strg+Shift+Enter (Test), Strg+S (Save) * - In-Browser Pyodide WebAssembly Python Runner (95% Client-Execution) - * - Automatischer Test-Auswertung mit XP-Vergabe - * - Cloud Auto-Save + * - Endlosschleifen-Timeout-Schutz (5000ms) + * - Reaktivität mit Fehler-Dolmetscher & Gamification */ (function () { @@ -15,6 +16,16 @@ let currentChapterData = null; let autoSaveTimeout = null; + function escapeHtml(text) { + if (!text) return ""; + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + // 1. Initialisiere Monaco Editor via CDN function initMonacoEditor(initialCode = "") { const editorContainer = document.getElementById("monaco-editor-container"); @@ -25,7 +36,6 @@ return; } - // Monaco CDN Loader const loaderScript = document.createElement("script"); loaderScript.src = "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"; loaderScript.onload = () => { @@ -37,8 +47,7 @@ }); }; loaderScript.onerror = () => { - // Fallback: Einfache Textarea falls CDN offline - editorContainer.innerHTML = ``; + editorContainer.innerHTML = ``; }; document.head.appendChild(loaderScript); } @@ -63,12 +72,31 @@ bracketPairColorization: { enabled: true } }); - // Auto-Save Trigger beim Tippen + // Auto-Save beim Tippen monacoEditor.onDidChangeModelContent(() => { triggerAutoSave(); }); + + // Tastatur-Shortcuts in Monaco + monacoEditor.addCommand(window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.Enter, () => { + runCode(); + }); + monacoEditor.addCommand(window.monaco.KeyMod.CtrlCmd | window.monaco.KeyMod.Shift | window.monaco.KeyCode.Enter, () => { + runTests(); + }); + monacoEditor.addCommand(window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyS, () => { + saveProgressDraft(); + }); } + // Globaler Keydown-Listener für Strg+S + window.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + saveProgressDraft(); + } + }); + // 2. Pyodide WebAssembly Initialisierung async function getPyodide() { if (pyodideInstance) return pyodideInstance; @@ -98,7 +126,7 @@ stderr: (text) => logToTerminal(text + "\n", "stderr") }); - logToTerminal("✅ Python WebAssembly erfolgreich bereitgestellt!\n\n", "success"); + logToTerminal("✅ Python WebAssembly bereitgestellt!\n\n", "success"); } catch (err) { logToTerminal(`❌ Fehler beim Laden von Pyodide: ${err.message}\n`, "error"); } finally { @@ -107,17 +135,28 @@ return pyodideInstance; } - // 3. Code-Ausführung (Run) + // 3. Code-Ausführung mit Timeout-Schutz (5s) async function runCode() { const code = getEditorCode(); clearTerminal(); - logToTerminal("▶ Starte Python-Skript...\n----------------------------------------\n", "info"); + logToTerminal("▶ Starte Python-Skript (Strg+Enter)...\n----------------------------------------\n", "info"); const py = await getPyodide(); if (!py) return; try { - await py.runPythonAsync(code); + // Timeout-Rennen + let timeoutHandle; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error("Zeitüberschreitung: Dein Code lief länger als 5 Sekunden (Endlosschleife vermutet).")); + }, 5000); + }); + + const execPromise = py.runPythonAsync(code); + await Promise.race([execPromise, timeoutPromise]); + clearTimeout(timeoutHandle); + logToTerminal("\n----------------------------------------\n✨ Skript erfolgreich ausgeführt!\n", "success"); } catch (err) { logToTerminal(`\n❌ Ausführungsfehler:\n${err.message}\n`, "error"); @@ -134,16 +173,14 @@ const userCode = getEditorCode(); clearTerminal(); - logToTerminal("🧪 Führe automatisierte Unittests aus...\n----------------------------------------\n", "info"); + logToTerminal("🧪 Führe automatisierte Unittests aus (Strg+Shift+Enter)...\n----------------------------------------\n", "info"); const py = await getPyodide(); if (!py) return; try { - // 1. Schreibe aufgabe.py in das Pyodide-Dateisystem py.FS.writeFile("aufgabe.py", userCode); - // 2. Führe test_aufgabe.py aus const testRunnerScript = ` import unittest, io, sys from unittest import TextTestRunner @@ -169,7 +206,18 @@ errors = len(result.errors) (success, total_runs, failures, errors, output_text) `; - const [isSuccess, totalRuns, fails, errs, outputText] = await py.runPythonAsync(testRunnerScript); + let timeoutHandle; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error("Zeitüberschreitung: Unittest lief länger als 6 Sekunden.")); + }, 6000); + }); + + const [isSuccess, totalRuns, fails, errs, outputText] = await Promise.race([ + py.runPythonAsync(testRunnerScript), + timeoutPromise + ]); + clearTimeout(timeoutHandle); logToTerminal(outputText + "\n", "stdout"); @@ -225,17 +273,15 @@ errors = len(result.errors) clearTimeout(autoSaveTimeout); autoSaveTimeout = setTimeout(() => { saveProgressDraft(); - }, 1500); // 1.5s nach letztem Tastenanschlag speichern + }, 1500); } async function saveProgressDraft() { if (!currentChapterData || !currentChapterData.chapterId) return; const code = getEditorCode(); - // 1. Lokal in LocalStorage localStorage.setItem(`code_draft_${currentChapterData.chapterId}`, code); - // 2. In Backend-Cloud falls angemeldet const token = localStorage.getItem("auth_token"); if (token) { try { @@ -266,12 +312,12 @@ errors = len(result.errors) async function recordChapterSolved() { if (!currentChapterData || !currentChapterData.chapterId) return; - // 1. Lokale Gamification + // Lokale Gamification if (window.addXP) { window.addXP(100, currentChapterData.chapterId); } - // 2. Backend Cloud + // Backend Cloud const token = localStorage.getItem("auth_token"); if (token) { try { @@ -307,19 +353,20 @@ errors = len(result.errors) errorTab.style.borderColor = "var(--danger)"; errorTab.innerHTML = "🩺 Fehler-Dolmetscher ⚠️"; } + if (window.interpretWorkspaceError) { + window.interpretWorkspaceError(); + } } // 6. Init Workspace Controller window.initWorkspace = function (chapterData) { currentChapterData = chapterData; - // Lade gespeicherten Code-Draft oder Startercode const savedDraft = localStorage.getItem(`code_draft_${chapterData.chapterId}`); const initialCode = savedDraft || chapterData.starterCode || ""; initMonacoEditor(initialCode); - // Event Listener für Buttons document.getElementById("btn-run-code")?.addEventListener("click", runCode); document.getElementById("btn-run-tests")?.addEventListener("click", runTests); document.getElementById("btn-save-code")?.addEventListener("click", saveProgressDraft); diff --git a/server.py b/server.py index 1600411..d0abae8 100644 --- a/server.py +++ b/server.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 """ -🐍 PYTHON LERNPLATTFORM – BACKEND SERVER 🐍 -=========================================== -Leichtgewichtiger, abhängigkeitsfreier REST-API & Static Server für die Online-Lernplattform. -- Läuft auf jedem Linux-Server / Webspace mit Standard-Python 3. -- SQLite-Datenbank (User-Accounts, Code-Drafts, XP, Klassen & Lehrer-Dashboard). -- PBKDF2-HMAC-SHA256 Passwort-Verschlüsselung & signierte Session-Tokens. +🐍 PYTHON LERNPLATTFORM – GEHÄRTETER BACKEND SERVER 🐍 +====================================================== +Sicherheitsoptimierter, abhängigkeitsfreier REST-API & Static Server. +- Persistenter SECRET_KEY (Env / geschützte .secret_key Datei). +- Schutz gegen User-Enumeration (Constant-Time Dummy PBKDF2). +- In-Memory Rate-Limiting gegen Brute-Force-Angriffe. +- Autorisierungsprüfung (IDOR-Schutz) in allen Lehrer-Endpunkten. +- Striktes Blockieren sensitiver Dateien (.db, .secret_key, .py, .php, .git) im Static-Handler. +- Security-Header (CSP, X-Content-Type-Options, X-Frame-Options). +- Neuer Code-Inspektor für Lehrkräfte, CSV-Matrix-Export & Zertifikats-Verifikation. """ import http.server @@ -17,18 +21,67 @@ import hmac import secrets import time import os +import re +import uuid import urllib.parse from pathlib import Path +from collections import defaultdict PORT = int(os.environ.get("PORT", 8000)) BASE_DIR = Path(__file__).parent.resolve() DB_FILE = BASE_DIR / "platform_data.db" -SECRET_KEY = secrets.token_hex(32) +KEY_FILE = BASE_DIR / ".secret_key" -# ============================================================================== -# DATENBANK INITIALISIERUNG -# ============================================================================== +# ------------------------------------------------------------------------------ +# 1. PERSISTENTER SECRET KEY +# ------------------------------------------------------------------------------ +def get_or_create_secret_key() -> str: + env_key = os.environ.get("SECRET_KEY") + if env_key and len(env_key) >= 32: + return env_key + if KEY_FILE.exists(): + try: + with open(KEY_FILE, "r", encoding="utf-8") as f: + key = f.read().strip() + if len(key) >= 32: + return key + except Exception: + pass + + new_key = secrets.token_hex(32) + try: + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + mode = 0o600 + fd = os.open(str(KEY_FILE), flags, mode) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(new_key) + except Exception as e: + print(f"⚠️ Warnung: Konnte .secret_key nicht schreiben: {e}") + return new_key + +SECRET_KEY = get_or_create_secret_key() + +# ------------------------------------------------------------------------------ +# 2. IN-MEMORY RATE LIMITER +# ------------------------------------------------------------------------------ +class SimpleRateLimiter: + def __init__(self): + self.attempts = defaultdict(list) + + def is_allowed(self, key: str, max_requests: int = 5, window_seconds: int = 60) -> bool: + now = time.time() + self.attempts[key] = [t for t in self.attempts[key] if now - t < window_seconds] + if len(self.attempts[key]) >= max_requests: + return False + self.attempts[key].append(now) + return True + +rate_limiter = SimpleRateLimiter() + +# ------------------------------------------------------------------------------ +# 3. DATENBANK INITIALISIERUNG +# ------------------------------------------------------------------------------ def get_db(): conn = sqlite3.connect(str(DB_FILE)) conn.row_factory = sqlite3.Row @@ -37,8 +90,6 @@ def get_db(): def init_db(): conn = get_db() c = conn.cursor() - - # 1. Benutzer-Tabelle c.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -54,7 +105,6 @@ def init_db(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )""") - # 2. Kapitel-Fortschritt & Code-Drafts c.execute(""" CREATE TABLE IF NOT EXISTS chapter_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -69,7 +119,6 @@ def init_db(): FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )""") - # 3. Klassenräume (Lehrer-Feature) c.execute(""" CREATE TABLE IF NOT EXISTS classrooms ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -80,7 +129,6 @@ def init_db(): FOREIGN KEY(teacher_id) REFERENCES users(id) ON DELETE CASCADE )""") - # 4. Klassen-Mitgliedschaften c.execute(""" CREATE TABLE IF NOT EXISTS class_enrollments ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -92,24 +140,23 @@ def init_db(): FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )""") - # 5. Freigeschaltete Trophäen c.execute(""" - CREATE TABLE IF NOT EXISTS achievements ( + CREATE TABLE IF NOT EXISTS certificates ( id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL, - trophy_id TEXT NOT NULL, - unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, trophy_id), + track_id TEXT NOT NULL, + student_name TEXT NOT NULL, + issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )""") conn.commit() conn.close() -# ============================================================================== -# AUTHENTIFIZIERUNG & HASHING -# ============================================================================== - +# ------------------------------------------------------------------------------ +# 4. AUTH & HASHING +# ------------------------------------------------------------------------------ def hash_password(password: str, salt: str = None) -> tuple[str, str]: if not salt: salt = secrets.token_hex(16) @@ -122,14 +169,18 @@ def verify_password(password: str, password_hash: str, salt: str) -> bool: new_hash, _ = hash_password(password, salt) return hmac.compare_digest(new_hash, password_hash) +# Constant-Time Dummy PBKDF2 gegen User-Enumeration +DUMMY_SALT = secrets.token_hex(16) +DUMMY_HASH, _ = hash_password("dummy_password_constant_time", DUMMY_SALT) + def generate_token(user_id: int, email: str, role: str) -> str: payload = { "uid": user_id, "email": email, "role": role, - "exp": int(time.time()) + (86400 * 30) # 30 Tage gültig + "exp": int(time.time()) + (86400 * 30) } - payload_json = json.dumps(payload) + payload_json = json.dumps(payload, sort_keys=True) sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_json.encode("utf-8"), hashlib.sha256).hexdigest() token = f"{payload_json}.{sig}" return urllib.parse.quote(token) @@ -150,18 +201,19 @@ def verify_token(token_str: str) -> dict | None: except Exception: return None -# ============================================================================== -# HTTP REQUEST HANDLER -# ============================================================================== - +# ------------------------------------------------------------------------------ +# 5. HTTP REQUEST HANDLER +# ------------------------------------------------------------------------------ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(BASE_DIR), **kwargs) def end_headers(self): - # CORS & Cache-Header für moderne Web-Apps + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "SAMEORIGIN") + self.send_header("Referrer-Policy", "strict-origin-when-cross-origin") self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE") self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") self.send_header("Cache-Control", "no-cache, must-revalidate") super().end_headers() @@ -176,6 +228,12 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): self.end_headers() self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8")) + def get_client_ip(self) -> str: + forwarded = self.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return self.client_address[0] + def get_auth_user(self) -> dict | None: auth_header = self.headers.get("Authorization", "") if auth_header.startswith("Bearer "): @@ -185,16 +243,30 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): def read_json_body(self) -> dict: content_length = int(self.headers.get("Content-Length", 0)) - if content_length == 0: + if content_length == 0 or content_length > 1_000_000: return {} body = self.rfile.read(content_length) return json.loads(body.decode("utf-8")) + def is_path_blocked(self, path: str) -> bool: + clean_path = path.split("?")[0].lower() + blocked_extensions = (".db", ".sqlite", ".secret_key", ".py", ".php", ".jsonl") + if any(clean_path.endswith(ext) for ext in blocked_extensions): + return True + if "/." in clean_path or clean_path.startswith("/."): + return True + if "platform_data.db" in clean_path or ".gamification.json" in clean_path: + return True + return False + def do_GET(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path - # --- API ROUTES --- + if self.is_path_blocked(path): + return self.send_json({"error": "Zugriff verweigert (Geschützte Datei)"}, 403) + + # 1. AUTH ME if path == "/api/auth/me": user_token = self.get_auth_user() if not user_token: @@ -206,6 +278,7 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): return self.send_json({"error": "Benutzer nicht gefunden"}, 404) return self.send_json({"user": dict(row)}) + # 2. PROGRESS GET elif path.startswith("/api/progress/get"): user_token = self.get_auth_user() if not user_token: @@ -223,6 +296,7 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): conn.close() return self.send_json({"all_progress": [dict(r) for r in rows]}) + # 3. CLASSROOMS LIST elif path.startswith("/api/classrooms/list"): user_token = self.get_auth_user() if not user_token: @@ -248,6 +322,7 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): conn.close() return self.send_json({"classrooms": [dict(c) for c in classes]}) + # 4. CLASSROOMS MATRIX (mit IDOR-Schutz) elif path.startswith("/api/classrooms/matrix"): user_token = self.get_auth_user() if not user_token or user_token.get("role") != "teacher": @@ -255,16 +330,21 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): query = urllib.parse.parse_qs(parsed.query) classroom_id = query.get("classroom_id", [""])[0] - if not classroom_id: - return self.send_json({"error": "classroom_id fehlt"}, 400) + if not classroom_id or not classroom_id.isdigit(): + return self.send_json({"error": "Ungültige classroom_id"}, 400) conn = get_db() + owner_check = conn.execute("SELECT id, name FROM classrooms WHERE id = ? AND teacher_id = ?", (int(classroom_id), user_token["uid"])).fetchone() + if not owner_check: + conn.close() + return self.send_json({"error": "Zugriff verweigert (Nicht deine Klasse)"}, 403) + students = conn.execute(""" SELECT u.id, u.name, u.email, u.xp, u.level, u.streak_days FROM class_enrollments ce JOIN users u ON ce.user_id = u.id WHERE ce.classroom_id = ? - """, (classroom_id,)).fetchall() + """, (int(classroom_id),)).fetchall() matrix = [] for st in students: @@ -274,7 +354,58 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): "solved_chapters": {r["chapter_id"]: bool(r["is_solved"]) for r in solved} }) conn.close() - return self.send_json({"matrix": matrix}) + return self.send_json({"classroom": dict(owner_check), "matrix": matrix}) + + # 5. SCHÜLER-CODE INSPEKTOR FÜR LEHRER + elif path.startswith("/api/classrooms/student-code"): + user_token = self.get_auth_user() + if not user_token or user_token.get("role") != "teacher": + return self.send_json({"error": "Nur für Lehrkräfte"}, 403) + + query = urllib.parse.parse_qs(parsed.query) + classroom_id = query.get("classroom_id", [""])[0] + student_id = query.get("student_id", [""])[0] + chapter_id = query.get("chapter_id", [""])[0] + + if not classroom_id or not student_id or not chapter_id: + return self.send_json({"error": "Fehlende Parameter"}, 400) + + conn = get_db() + # Prüfe Autorisierung + auth_check = conn.execute(""" + SELECT c.id FROM classrooms c + JOIN class_enrollments ce ON c.id = ce.classroom_id + WHERE c.id = ? AND c.teacher_id = ? AND ce.user_id = ? + """, (int(classroom_id), user_token["uid"], int(student_id))).fetchone() + + if not auth_check: + conn.close() + return self.send_json({"error": "Zugriff verweigert"}, 403) + + progress = conn.execute("SELECT chapter_id, code_draft, subgoals_json, is_solved, solved_at FROM chapter_progress WHERE user_id = ? AND chapter_id = ?", (int(student_id), chapter_id)).fetchone() + student = conn.execute("SELECT id, name, email FROM users WHERE id = ?", (int(student_id),)).fetchone() + conn.close() + + return self.send_json({ + "student": dict(student) if student else None, + "progress": dict(progress) if progress else None + }) + + # 6. ZERTIFIKATS-VERIFIKATION + elif path.startswith("/api/certificates/verify"): + query = urllib.parse.parse_qs(parsed.query) + cert_uuid = query.get("uuid", [""])[0].strip() + + if not cert_uuid: + return self.send_json({"error": "uuid fehlt"}, 400) + + conn = get_db() + cert = conn.execute("SELECT uuid, track_id, student_name, issued_at FROM certificates WHERE uuid = ?", (cert_uuid,)).fetchone() + conn.close() + + if not cert: + return self.send_json({"valid": False, "error": "Zertifikat nicht gefunden"}, 404) + return self.send_json({"valid": True, "certificate": dict(cert)}) elif path == "/api/stats/leaderboard": conn = get_db() @@ -282,12 +413,12 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): conn.close() return self.send_json({"leaderboard": [dict(r) for r in rows]}) - # --- STATISCHE DATEIEN --- return super().do_GET() def do_POST(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path + client_ip = self.get_client_ip() try: body = self.read_json_body() @@ -296,15 +427,22 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): # 1. REGISTRIERUNG if path == "/api/auth/register": - email = body.get("email", "").strip().lower() - password = body.get("password", "") - name = body.get("name", "").strip() - role = body.get("role", "student") + if not rate_limiter.is_allowed(f"reg_{client_ip}", max_requests=5, window_seconds=60): + return self.send_json({"error": "Zu viele Registrierungsversuche. Bitte warte eine Minute."}, 429) - if not email or not password or not name: - return self.send_json({"error": "Name, E-Mail und Passwort sind erforderlich"}, 400) - if len(password) < 6: - return self.send_json({"error": "Passwort muss mindestens 6 Zeichen lang sein"}, 400) + email = str(body.get("email", "")).strip().lower() + password = str(body.get("password", "")) + name = str(body.get("name", "")).strip() + role = str(body.get("role", "student")).strip() + + if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email) or len(email) > 120: + return self.send_json({"error": "Ungültige E-Mail-Adresse"}, 400) + if len(password) < 6 or len(password) > 128: + return self.send_json({"error": "Passwort muss zwischen 6 und 128 Zeichen lang sein"}, 400) + if not name or len(name) > 60: + return self.send_json({"error": "Name muss zwischen 1 und 60 Zeichen lang sein"}, 400) + if role not in ("student", "teacher"): + role = "student" pwd_hash, salt = hash_password(password) conn = get_db() @@ -325,16 +463,23 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): finally: conn.close() - # 2. LOGIN + # 2. LOGIN (mit Constant-Time Schutz gegen Enumeration) elif path == "/api/auth/login": - email = body.get("email", "").strip().lower() - password = body.get("password", "") + if not rate_limiter.is_allowed(f"login_{client_ip}", max_requests=8, window_seconds=60): + return self.send_json({"error": "Zu viele Login-Versuche. Bitte warte eine Minute."}, 429) + + email = str(body.get("email", "")).strip().lower() + password = str(body.get("password", "")) conn = get_db() user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone() conn.close() - if not user or not verify_password(password, user["password_hash"], user["salt"]): + if not user: + verify_password(password, DUMMY_HASH, DUMMY_SALT) + return self.send_json({"error": "Ungültige E-Mail-Adresse oder Passwort"}, 401) + + if not verify_password(password, user["password_hash"], user["salt"]): return self.send_json({"error": "Ungültige E-Mail-Adresse oder Passwort"}, 401) token = generate_token(user["id"], user["email"], user["role"]) @@ -358,9 +503,9 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): if not user_token: return self.send_json({"error": "Nicht authentifiziert"}, 401) - chapter_id = body.get("chapter_id", "") - code_draft = body.get("code_draft", "") - subgoals_json = json.dumps(body.get("subgoals", {})) + chapter_id = str(body.get("chapter_id", ""))[:120] + code_draft = str(body.get("code_draft", ""))[:100_000] + subgoals_json = json.dumps(body.get("subgoals", {}))[:10_000] if not chapter_id: return self.send_json({"error": "chapter_id erforderlich"}, 400) @@ -379,22 +524,23 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): return self.send_json({"status": "saved", "chapter_id": chapter_id}) - # 4. KAPITEL ALS GELÖST MARKIEREN & XP VERGEBEN + # 4. PROGRESS SOLVE elif path == "/api/progress/solve": user_token = self.get_auth_user() if not user_token: return self.send_json({"error": "Nicht authentifiziert"}, 401) - chapter_id = body.get("chapter_id", "") - xp_reward = int(body.get("xp_reward", 100)) + chapter_id = str(body.get("chapter_id", ""))[:120] + try: + xp_reward = min(max(0, int(body.get("xp_reward", 100))), 100) + except (ValueError, TypeError): + xp_reward = 100 if not chapter_id: return self.send_json({"error": "chapter_id erforderlich"}, 400) conn = get_db() - # Prüfe, ob das Kapitel bereits gelöst war existing = conn.execute("SELECT is_solved FROM chapter_progress WHERE user_id = ? AND chapter_id = ?", (user_token["uid"], chapter_id)).fetchone() - was_already_solved = existing and existing["is_solved"] == 1 if not was_already_solved: @@ -407,7 +553,6 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): updated_at = CURRENT_TIMESTAMP """, (user_token["uid"], chapter_id)) - # XP & Level berechnen user_row = conn.execute("SELECT xp FROM users WHERE id = ?", (user_token["uid"],)).fetchone() new_xp = (user_row["xp"] if user_row else 0) + xp_reward new_level = (new_xp // 100) + 1 @@ -420,13 +565,13 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): conn.close() return self.send_json({"status": "already_solved", "earned_xp": 0}) - # 5. KLASSENRAUM ERSTELLEN (LEHRER) + # 5. KLASSENRAUM ERSTELLEN elif path == "/api/classrooms/create": user_token = self.get_auth_user() if not user_token or user_token.get("role") != "teacher": return self.send_json({"error": "Nur für Lehrkräfte gestattet"}, 403) - name = body.get("name", "").strip() + name = str(body.get("name", "")).strip()[:80] if not name: return self.send_json({"error": "Klassenname erforderlich"}, 400) @@ -441,13 +586,16 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): return self.send_json({"classroom": {"id": class_id, "name": name, "invite_code": invite_code}}, 201) - # 6. KLASSENRAUM BEITRETEN (SCHÜLER) + # 6. KLASSENRAUM BEITRETEN elif path == "/api/classrooms/join": + if not rate_limiter.is_allowed(f"join_{client_ip}", max_requests=10, window_seconds=60): + return self.send_json({"error": "Zu viele Versuche. Bitte kurz warten."}, 429) + user_token = self.get_auth_user() if not user_token: return self.send_json({"error": "Nicht authentifiziert"}, 401) - code = body.get("invite_code", "").strip().upper() + code = str(body.get("invite_code", "")).strip().upper()[:12] conn = get_db() cl = conn.execute("SELECT id, name FROM classrooms WHERE invite_code = ?", (code,)).fetchone() if not cl: @@ -463,11 +611,37 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): conn.close() return self.send_json({"message": "Du bist dieser Klasse bereits beigetreten.", "classroom": dict(cl)}) - return self.send_json({"error": "Endpunkt nicht gefunden"}, 404) + # 7. KLASSE LÖSCHEN (LEHRER) + elif path == "/api/classrooms/delete": + user_token = self.get_auth_user() + if not user_token or user_token.get("role") != "teacher": + return self.send_json({"error": "Nur für Lehrkräfte"}, 403) -# ============================================================================== -# SERVER START -# ============================================================================== + class_id = body.get("classroom_id") + conn = get_db() + conn.execute("DELETE FROM classrooms WHERE id = ? AND teacher_id = ?", (class_id, user_token["uid"])) + conn.commit() + conn.close() + return self.send_json({"status": "deleted"}) + + # 8. ZERTIFIKAT AUSSTELLEN + elif path == "/api/certificates/create": + user_token = self.get_auth_user() + if not user_token: + return self.send_json({"error": "Nicht authentifiziert"}, 401) + + track_id = str(body.get("track_id", ""))[:60] + student_name = str(body.get("student_name", ""))[:100] + cert_uuid = str(uuid.uuid4()) + + conn = get_db() + conn.execute("INSERT INTO certificates (uuid, user_id, track_id, student_name) VALUES (?, ?, ?, ?)", + (cert_uuid, user_token["uid"], track_id, student_name)) + conn.commit() + conn.close() + return self.send_json({"uuid": cert_uuid, "track_id": track_id, "student_name": student_name}) + + return self.send_json({"error": "Endpunkt nicht gefunden"}, 404) def run_server(): init_db() @@ -475,7 +649,7 @@ def run_server(): socketserver.TCPServer.allow_reuse_address = True with socketserver.TCPServer(("", PORT), handler) as httpd: print(f"🚀 Python Lernplattform Server läuft auf: http://localhost:{PORT}") - print(f"📁 Root-Verzeichnis: {BASE_DIR}") + print(f"🔒 Secret Key Status: Aktiv (Persistiert)") print(f"💾 SQLite-Datenbank: {DB_FILE}") try: httpd.serve_forever() diff --git a/teacher.html b/teacher.html index 41adae0..9cc0442 100644 --- a/teacher.html +++ b/teacher.html @@ -75,7 +75,7 @@
    👨‍🏫 Dozenten- & Lehrer-Portal

    Klassenverwaltung & Lernfortschritts-Matrix

    -

    Behalte den Überblick über deine Klassen, lade Schüler per Code ein und verfolge gelöste Aufgaben in Echtzeit.

    +

    Behalte den Überblick über deine Klassen, lade Schüler per Code ein, inspiziere ihren Code und exportiere Notenlisten als CSV.

    @@ -108,8 +108,28 @@ + + +