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)}
+
+ 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 = `
-
-
+ // 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;
-
-
🎖️ Dein Trophäenraum
-
- ${Object.keys(badgesKatalog).map(bId => {
- const b = badgesKatalog[bId];
- const isUnlocked = unlockedBadges.has(bId);
- return `
-
-
${isUnlocked ? b.icon : '🔒'}
-
-
${b.name}
-
${b.desc}
-
${isUnlocked ? '✅ Freigeschaltet' : '🔒 Gesperrt'}
-
-
- `;
- }).join("")}
-
-
-
- `;
+ 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!
-
-
-
-
🔍 Fehlermeldung übersetzen
-
-
-
- `;
+ 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:
${res.causes.map(c => `${c} `).join("")} ` : ''}
-
-
🔧 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 @@
@@ -108,8 +108,28 @@
+
+
+
+
+
🔍 Code-Inspektor
+ ✕
+
+
+
+
+ Schließen
+
+
+
+