Review-Batch P0: Sicherheit + Schueler-Ergebnisse + Assessments-Aufblaehung

Sicherheit:
- progress.php: IDOR geschlossen (student_id-Zweig verlangt Lehrer-Login + Klassenbesitz)
- licenses.php: ?admin=1 und generate nur noch fuer echten Admin; Collision-Retry mit Cap
- .htaccess: .env/.sh/.sql/.bak Deny (Defense-in-Depth)

Schueler-Ergebnisse (kritisch):
- sim-metrics.js + .ivm-CSS in schueler.html; renderOwnRunCard-Fallback zeigt jetzt
  Sterne + grafische Kennzahlen fuer die 9 bisher leeren Sims
- sim-metrics.js: klima final_budget als "Mio EUR" statt "EUR" (Faktor 10^6)

Skalierung:
- assessment.php: Zwischen-Pings (phase running/started) nicht mehr persistiert
- heli: 30-s-assessmentPing entfernt (Live-Zustand laeuft ueber Heartbeat)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 16:21:25 +02:00
parent a3c5753cd7
commit 2984d93e7e
7 changed files with 65 additions and 14 deletions
+9
View File
@@ -1,3 +1,12 @@
# Sensible Dateien niemals ausliefern (Defense-in-Depth), auch wenn sie im
# Docroot liegen und die -f-Regel unten sie sonst direkt serviert.
<FilesMatch "^\.env">
Require all denied
</FilesMatch>
<FilesMatch "\.(sh|sql|bak)$">
Require all denied
</FilesMatch>
RewriteEngine On RewriteEngine On
# Kein RewriteBase — Apache nutzt den Request-Pfad automatisch. # Kein RewriteBase — Apache nutzt den Request-Pfad automatisch.
# Funktioniert fuer alle Deploy-Ziele: # Funktioniert fuer alle Deploy-Ziele:
+1 -1
View File
@@ -20,7 +20,7 @@
{k:'final_temperature',type:'temp',l:'Temperatur'}, {k:'final_temperature',type:'temp',l:'Temperatur'},
{k:'final_flooded_pct',type:'pctInv',l:'Überflutung'}, {k:'final_flooded_pct',type:'pctInv',l:'Überflutung'},
{k:'final_co2',type:'num',l:'CO₂',u:'ppm',dir:'lo'}, {k:'final_co2',type:'num',l:'CO₂',u:'ppm',dir:'lo'},
{k:'final_budget',type:'money',l:'Budget'}, {k:'final_budget',type:'num',l:'Budget',u:'Mio €'},
{k:'final_population',type:'num',l:'Bevölkerung'}, {k:'final_population',type:'num',l:'Bevölkerung'},
{k:'measures_bought',type:'num',l:'Maßnahmen'} {k:'measures_bought',type:'num',l:'Maßnahmen'}
], ],
+10
View File
@@ -25,6 +25,16 @@ if (!defined('GGS_MAX_SESSION_MS')) define('GGS_MAX_SESSION_MS', 7200000);
$rawDuration = (int)($body['duration'] ?? 0); $rawDuration = (int)($body['duration'] ?? 0);
$durationMs = max(0, min($rawDuration, GGS_MAX_SESSION_MS)); $durationMs = max(0, min($rawDuration, GGS_MAX_SESSION_MS));
// Zwischen-Telemetrie eines laufenden Durchgangs NICHT in assessments persistieren.
// Der Live-Zustand für das Lehrer-Cockpit kommt über den Heartbeat (live_sessions);
// diese 30-s-Pings (phase 'running'/'started') haben früher die Tabelle zugemüllt
// (~96 % der Zeilen). Nur echte Abschlüsse/Ergebnisse werden gespeichert.
$pl = $body['processLog'] ?? [];
$phase = (is_array($pl) && isset($pl['phase'])) ? $pl['phase'] : null;
if ($phase === 'running' || $phase === 'started') {
Response::ok(['skipped' => true]);
}
$db->execute( $db->execute(
'INSERT INTO assessments (session_id, sim_id, class_id, process_log, predictions, results, reflections, duration_ms, completed_phases) 'INSERT INTO assessments (session_id, sim_id, class_id, process_log, predictions, results, reflections, duration_ms, completed_phases)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+8 -7
View File
@@ -15,8 +15,7 @@ if ($method === 'GET') {
if (isset($_GET['admin']) && $_GET['admin'] === '1') { if (isset($_GET['admin']) && $_GET['admin'] === '1') {
Session::start(); Session::start();
$adminId = $_SESSION['admin_id'] ?? null; $adminId = $_SESSION['admin_id'] ?? null;
$teacherId = Session::teacherId(); if (!$adminId) {
if (!$adminId && !$teacherId) {
Response::error('Nicht autorisiert', 401); Response::error('Nicht autorisiert', 401);
} }
@@ -169,16 +168,18 @@ if ($method === 'POST') {
Response::ok(['licenseId' => (int)$license['id']]); Response::ok(['licenseId' => (int)$license['id']]);
} }
// Super-Admin: neue Lizenzen generieren // Super-Admin: neue Lizenzen generieren (NUR echter Admin)
if ($action === 'generate') { if ($action === 'generate') {
$teacherId = Session::requireTeacher(); Session::start();
if (empty($_SESSION['admin_id'])) Response::error('Nur Admin', 403);
$count = min(1000, max(1, (int)($body['count'] ?? 100))); $count = min(1000, max(1, (int)($body['count'] ?? 100)));
$year = (int)($body['year'] ?? date('Y')); $year = (int)($body['year'] ?? date('Y'));
$schoolYear = ($year % 100) . '/' . (($year % 100) + 1); $schoolYear = ($year % 100) . '/' . (($year % 100) + 1);
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$generated = 0; $generated = 0; $tries = 0; $maxTries = $count * 10 + 100;
for ($i = 0; $i < $count; $i++) { while ($generated < $count && $tries < $maxTries) {
$tries++;
$code = ''; $code = '';
for ($j = 0; $j < 3; $j++) { for ($j = 0; $j < 3; $j++) {
if ($j > 0) $code .= '-'; if ($j > 0) $code .= '-';
@@ -189,7 +190,7 @@ if ($method === 'POST') {
$db->execute('INSERT INTO licenses (code, school_year) VALUES (?, ?)', [$code, $schoolYear]); $db->execute('INSERT INTO licenses (code, school_year) VALUES (?, ?)', [$code, $schoolYear]);
$generated++; $generated++;
} catch (\Exception $e) { } catch (\Exception $e) {
$i--; // Collision, retry // Kollision (unwahrscheinlich) → nächster Versuch; $maxTries schützt vor Endlosschleife
} }
} }
Response::ok(['generated' => $generated]); Response::ok(['generated' => $generated]);
+14 -3
View File
@@ -19,10 +19,21 @@ $db = Database::get();
if ($method === 'GET') { if ($method === 'GET') {
$simId = $_GET['sim_id'] ?? ''; $simId = $_GET['sim_id'] ?? '';
$studentId = (int)($_GET['student_id'] ?? 0); $reqStudentId = (int)($_GET['student_id'] ?? 0);
$studentId = 0;
// Schüler*in: eigener Fortschritt if ($reqStudentId) {
if ($simId && !$studentId) { // Lehrer-Ansicht: nur eingeloggte Lehrperson, und die:der Schüler:in
// MUSS zu einer Klasse dieser Lehrperson gehören (sonst IDOR).
$teacherId = Session::requireTeacher();
$owned = $db->fetchOne(
'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?',
[$reqStudentId, $teacherId]
);
if (!$owned) Response::error('Kein Zugriff auf diese:n Schüler:in', 403);
$studentId = $reqStudentId;
} elseif ($simId) {
// Schüler*in: eigener Fortschritt (aus der Session abgeleitet)
$sessionId = Session::requireStudent(); $sessionId = Session::requireStudent();
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]); $session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]);
if ($session) { if ($session) {
+21 -1
View File
@@ -95,8 +95,18 @@
.hidden{display:none} .hidden{display:none}
@media(max-width:550px){.mod-grid{grid-template-columns:1fr}.badge-grid{grid-template-columns:repeat(2,1fr)}} @media(max-width:550px){.mod-grid{grid-template-columns:1fr}.badge-grid{grid-template-columns:repeat(2,1fr)}}
/* Gemeinsames Sim-Kennzahlen-Schema (renderSimMetrics aus sim-metrics.js) */
.ivm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:7px;margin-top:.5rem}
.ivm{background:#f4f1e8;border-radius:8px;padding:6px 9px;min-width:0}
.ivm-l{display:block;font-size:.58rem;color:#8a8a8a;text-transform:uppercase;letter-spacing:.03em;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ivm-v{font-size:.92rem;font-weight:750}.ivm-u{font-size:.66rem;font-weight:600;color:#8a8a8a}.ivm-txt{font-size:.8rem;font-weight:700}
.ivm-stars{color:#e0a838;font-size:.95rem;letter-spacing:1px}.ivm-stars.sm{font-size:.8rem}.ivm-star-off{color:#ddd}
.ivm-bar{display:block;height:7px;border-radius:4px;background:#ece8dc;overflow:hidden;margin-top:3px}.ivm-bar>i{display:block;height:100%;border-radius:4px;background:currentColor}
.ivm-g{color:#4a7c4e}.ivm-li{color:#7fa856}.ivm-y{color:#e0a838}.ivm-o{color:#e08a3a}.ivm-r{color:#c0574a}
</style> </style>
<script src="assets/js/badges.js"></script> <script src="assets/js/badges.js"></script>
<script src="assets/js/sim-metrics.js"></script>
</head> </head>
<body> <body>
<div class="topbar"> <div class="topbar">
@@ -496,7 +506,17 @@ function renderOwnRunCard(simId, run) {
if (simId === 'busfahrt') return renderBusfahrtOwnRun(dt, dur, r, d); if (simId === 'busfahrt') return renderBusfahrtOwnRun(dt, dur, r, d);
if (simId === 'fluggesellschaft') return renderFluggesellschaftOwnRun(dt, dur, r, d); if (simId === 'fluggesellschaft') return renderFluggesellschaftOwnRun(dt, dur, r, d);
if (simId === 'heli') return renderHeliOwnRun(dt, dur, r, d); if (simId === 'heli') return renderHeliOwnRun(dt, dur, r, d);
return '<div class="ownruns-card"><div class="ownruns-head"><strong>'+dt+'</strong><span class="ownruns-meta">'+dur+'</span></div></div>'; // Generisch: gemeinsames Kennzahlen-Schema (Sterne + grafische Kacheln) —
// deckt klima, klima-3d, farmer, fluss, logistik, weltkueche, kofferdetektiv,
// tourismustal, tourismusregion ab (vorher nur Datum + Dauer).
var st = (typeof d.stars === 'number') ? d.stars : (typeof d.sterneGesamt === 'number' ? d.sterneGesamt : null);
var starsHtml = '';
if (st != null) { var n = Math.max(0, Math.min(5, Math.round(st)));
starsHtml = '<span class="ownruns-stars">'+'★'.repeat(n)+'<span class="ownruns-stars-empty">'+'★'.repeat(5-n)+'</span></span>'; }
var metrics = (typeof renderSimMetrics === 'function') ? renderSimMetrics(simId, d) : '';
return '<div class="ownruns-card"><div class="ownruns-head"><strong>'+dt+'</strong>'
+ '<span class="ownruns-meta" style="margin-left:auto">'+dur+(starsHtml?' · '+starsHtml:'')+'</span></div>'
+ metrics + '</div>';
} }
function renderBusfahrtOwnRun(dt, dur, r, d) { function renderBusfahrtOwnRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(5, r.stars || 0)); var stars = Math.max(0, Math.min(5, r.stars || 0));
+2 -2
View File
@@ -683,9 +683,9 @@ function assessmentStart() {
duration: 0, duration: 0,
completedPhases: [], completedPhases: [],
}); });
// 30-s-Ping waehrend des Einsatzes // Kein 30-s-Zwischen-Ping mehr an /assessment — der Live-Zustand läuft über
// den Heartbeat (live_sessions). Früher blähte das die assessments-Tabelle auf.
if (heliAssessment.runTimer) clearInterval(heliAssessment.runTimer); if (heliAssessment.runTimer) clearInterval(heliAssessment.runTimer);
heliAssessment.runTimer = setInterval(assessmentPing, 30000);
} }
function assessmentPing() { function assessmentPing() {
if (!heliAssessment.startMs || !game.mission) return; if (!heliAssessment.startMs || !game.mission) return;