diff --git a/src/main/java/com/example/mindflux/config/BugHunterSeeder.java b/src/main/java/com/example/mindflux/config/BugHunterSeeder.java index 66ed67a..b11e232 100644 --- a/src/main/java/com/example/mindflux/config/BugHunterSeeder.java +++ b/src/main/java/com/example/mindflux/config/BugHunterSeeder.java @@ -16,16 +16,16 @@ public class BugHunterSeeder implements CommandLineRunner { private final BugHunterHuntRepository hunts; private final ObjectMapper mapper; public BugHunterSeeder(BugHunterHuntRepository hunts, ObjectMapper mapper) { this.hunts = hunts; this.mapper = mapper; } @Override @Transactional public void run(String... args) { - if (hunts.count() > 0) return; try { - JsonNode root = mapper.readTree(new ClassPathResource("bughunter/content.json").getInputStream()); int order = 0, count = 0; + JsonNode root = mapper.readTree(new ClassPathResource("bughunter/content.json").getInputStream()); int order = (int) hunts.count(), count = 0, added = 0; for (JsonNode node : root.path("hunts")) { + if (hunts.existsBySlug(node.path("slug").asText())) continue; BugHunterHunt hunt = new BugHunterHunt(node.path("slug").asText(), node.path("title").asText(), node.path("language").asText("Java"), node.path("difficulty").asText("junior"), node.path("examScope").asText("AP1"), node.path("description").asText(""), node.path("briefing").asText(""), node.path("consoleOutput").asText(""), order++); int fileOrder = 0; for (JsonNode file : node.path("files")) hunt.addFile(new BugHunterFile(file.path("filename").asText(), file.path("content").asText(), fileOrder++)); int bugOrder = 0; for (JsonNode bug : node.path("bugs")) { String payload = mapper.writeValueAsString(bug); if (payload.length() <= 8000) { hunt.addBug(new BugHunterBug(bug.path("points").asInt(100), bugOrder++, payload)); count++; } } - if (!hunt.getFiles().isEmpty() && !hunt.getBugs().isEmpty()) hunts.save(hunt); + if (!hunt.getFiles().isEmpty() && !hunt.getBugs().isEmpty()) { hunts.save(hunt); added++; } } - log.info("BugHunter: {} Fallakten geladen.", count); + log.info("BugHunter: {} neue Fallakten / {} Bugs geladen.", added, count); } catch (Exception ex) { log.error("BugHunter-Seed fehlgeschlagen", ex); } } } diff --git a/src/main/resources/bughunter/content.json b/src/main/resources/bughunter/content.json index b95cdd0..6291cee 100644 --- a/src/main/resources/bughunter/content.json +++ b/src/main/resources/bughunter/content.json @@ -38,6 +38,42 @@ {"filename":"Auftragsliste.java","content":"import java.util.*;\npublic class Auftragsliste {\n private final List jobs = new ArrayList<>();\n public void add(String job) { jobs.add(job); }\n public List jobs() { return jobs; }\n}"} ], "bugs": [{"points":200,"file":"Auftragsliste.java","line":5,"category":"sicherheit","evidence":"Die interne, veränderbare Liste wird direkt an parallele Aufrufer zurückgegeben.","fixOptions":[" public List jobs() { return List.copyOf(jobs); }"," public List jobs() { return new ArrayList<>(); }"," public List jobs() { return null; }"," public List jobs() { jobs.clear(); return jobs; }"],"fixIndex":0,"fixedLine":" public List jobs() { return List.copyOf(jobs); }","explanation":"Eine unveränderliche Momentaufnahme verhindert, dass externe Aufrufer die interne Liste gleichzeitig verändern. Für echte gemeinsame Mutation wäre zusätzlich eine threadsichere Datenstruktur nötig."}] + }, + { + "slug": "archiv-unter-druck", "title": "Fall #7: Archiv unter Druck", "language": "Java", "difficulty": "senior", "examScope": "AP2_FIAE", + "description": "Der tägliche Audit-Export bricht bei einem leeren Archiv ab und die CSV-Route stürzt nur in der Nachtverarbeitung ab.", "briefing": "Das Compliance-Team benötigt den Export vor 06:00 Uhr. Zwei Fehler liegen in getrennten Dateien; der Stacktrace nennt nur den Controller.", "consoleOutput": "java.lang.NullPointerException: Cannot invoke \"String.equals(Object)\"\n at ExportController.ausliefern(ExportController.java:4)\n[warn] Audit-Archiv enthält heute keine Einträge", + "files": [ + {"filename":"AuditExport.java","content":"import java.util.List;\n\npublic class AuditExport {\n public String export(List eintraege) {\n if (eintraege == null && eintraege.isEmpty()) {\n return \"Keine Einträge\";\n }\n return String.join(\"\\n\", eintraege);\n }\n}"}, + {"filename":"ExportController.java","content":"public class ExportController {\n public String ausliefern(String format, AuditExport export, java.util.List eintraege) {\n if (format.equals(\"csv\")) {\n return export.export(eintraege);\n }\n return \"Format nicht unterstützt\";\n }\n}"} + ], + "bugs": [ + {"points":150,"file":"AuditExport.java","line":5,"category":"laufzeit","evidence":"Bei null wird rechts von && trotzdem ein Methodenaufruf ausgeführt.","fixOptions":[" if (eintraege == null || eintraege.isEmpty()) {"," if (eintraege != null && eintraege.isEmpty()) {"," if (eintraege.isEmpty() || eintraege == null) {"," if (eintraege == null & eintraege.isEmpty()) {"],"fixIndex":0,"fixedLine":" if (eintraege == null || eintraege.isEmpty()) {","explanation":"Die ODER-Verknüpfung beendet die Auswertung sofort, wenn die Liste null ist. Danach kann isEmpty sicher geprüft werden."}, + {"points":150,"file":"ExportController.java","line":3,"category":"laufzeit","evidence":"Der Stacktrace zeigt auf den Methodenaufruf equals; format kann aus einem optionalen Jobparameter stammen.","fixOptions":[" if (\"csv\".equals(format)) {"," if (format == \"csv\") {"," if (format.equalsIgnoreCase == \"csv\") {"," if (format != \"csv\") {"],"fixIndex":0,"fixedLine":" if (\"csv\".equals(format)) {","explanation":"Wird die feste Zeichenkette links verwendet, liefert equals bei einem null-Wert einfach false statt eine NullPointerException auszulösen."} + ] + }, + { + "slug": "buchung-im-doppelpack", "title": "Fall #8: Buchung im Doppelpack", "language": "Java", "difficulty": "senior", "examScope": "AP2_FIAE", + "description": "Nach einem Retry erscheinen Bestellungen doppelt in der Buchhaltung; zusätzlich wird eine Nullzahlung nicht erkannt.", "briefing": "Ein Zahlungsanbieter sendet einen Retry, wenn die Antwort verzögert ist. Prüfe die Idempotenz und die Geldwert-Prüfung, bevor der Monatsabschluss startet.", "consoleOutput": "[payment] retry received for order A-1042\n[ledger] duplicate reference A-1042\n[warn] zero amount accepted", + "files": [ + {"filename":"BestellungService.java","content":"public class BestellungService {\n private final BestellungRepository repository;\n public BestellungService(BestellungRepository repository) { this.repository = repository; }\n\n public void speichern(Bestellung bestellung) {\n if (repository.existsByReferenz(bestellung.referenz())) {\n repository.save(bestellung);\n }\n }\n}"}, + {"filename":"ZahlungPruefung.java","content":"import java.math.BigDecimal;\n\npublic class ZahlungPruefung {\n static boolean istNullzahlung(BigDecimal betrag) {\n return betrag == BigDecimal.ZERO;\n }\n}"} + ], + "bugs": [ + {"points":150,"file":"BestellungService.java","line":6,"category":"logik","evidence":"Bei einer bereits bekannten Referenz wird die Bestellung erneut gespeichert.","fixOptions":[" if (!repository.existsByReferenz(bestellung.referenz())) {"," if (repository.existsByReferenz(bestellung.referenz()) == null) {"," if (repository.existsByReferenz(bestellung.referenz()) = false) {"," if (repository.existsByReferenz(bestellung.referenz()) || true) {"],"fixIndex":0,"fixedLine":" if (!repository.existsByReferenz(bestellung.referenz())) {","explanation":"Eine Referenz dient hier als Idempotenzschlüssel. Nur unbekannte Bestellungen dürfen den Speichervorgang auslösen."}, + {"points":150,"file":"ZahlungPruefung.java","line":4,"category":"logik","evidence":"BigDecimal-Werte können denselben Betrag darstellen, ohne dass sie dasselbe Objekt sind.","fixOptions":[" return betrag != null && betrag.compareTo(BigDecimal.ZERO) == 0;"," return betrag.equals(BigDecimal.ZERO) || betrag == null;"," return betrag == new BigDecimal(\"0\");"," return betrag.toString() == \"0\";"],"fixIndex":0,"fixedLine":" return betrag != null && betrag.compareTo(BigDecimal.ZERO) == 0;","explanation":"compareTo vergleicht den numerischen Wert unabhängig von der Skala. Zusätzlich verhindert die Null-Prüfung einen Absturz."} + ] + }, + { + "slug": "protokoll-ohne-schutz", "title": "Fall #9: Protokoll ohne Schutz", "language": "PHP", "difficulty": "principal", "examScope": "AP2_FIAE", + "description": "Ein Security-Audit findet Zugangsdaten im Log und eine unsichere Passwortprüfung im internen API-Gateway.", "briefing": "Das Incident-Team hat einen Logauszug gesichert. Finde beide Schwachstellen, ohne die Fehlersuche selbst unbrauchbar zu machen.", "consoleOutput": "[api] Token: eyJhbGciOiJIUzI1NiJ9...\nSecurity finding: CWE-532 Sensitive Information in Log Files\nSecurity finding: CWE-256 Plaintext Storage of a Password", + "files": [ + {"filename":"api.php","content":""}, + {"filename":"auth.php","content":""} + ], + "bugs": [ + {"points":200,"file":"api.php","line":3,"category":"sicherheit","evidence":"Der vollständige Bearer-Token wird in eine dauerhaft gespeicherte Logzeile übernommen.","fixOptions":[" error_log('API request ' . $path . ' with token [redigiert]');"," error_log('API request ' . $path . ' with token ' . substr($token, 0));"," error_log($token);"," error_log(base64_encode($token));"],"fixIndex":0,"fixedLine":" error_log('API request ' . $path . ' with token [redigiert]');","explanation":"Authentifizierungsdaten gehören nie in Logs. Für die Diagnose reichen Pfad, Request-ID und ein klarer Hinweis auf einen vorhandenen Token."}, + {"points":200,"file":"auth.php","line":3,"category":"sicherheit","evidence":"Das Passwort wird direkt mit einem gespeicherten Wert verglichen; sichere Passwort-Hashes benötigen eine spezialisierte Prüfung.","fixOptions":[" return password_verify($passwort, $user['password_hash']);"," return md5($passwort) == $user['password'];"," return $user['password'] === $passwort;"," return strlen($passwort) > 0;"],"fixIndex":0,"fixedLine":" return password_verify($passwort, $user['password_hash']);","explanation":"password_verify prüft einen Passwort-Hash inklusive Salt und Algorithmus sicher. Klartextvergleiche und MD5 sind dafür ungeeignet."} + ] } ] } diff --git a/src/main/resources/static/css/bughunter.css b/src/main/resources/static/css/bughunter.css index ae2a2d6..5a5b565 100644 --- a/src/main/resources/static/css/bughunter.css +++ b/src/main/resources/static/css/bughunter.css @@ -1 +1 @@ -.bh-app{--accent:var(--bh-accent);--glow-s:var(--bh-glow);min-height:100vh;color:var(--text);background:radial-gradient(circle at 10% 0,color-mix(in srgb,var(--bh-accent) 14%,transparent),transparent 33%),linear-gradient(135deg,rgba(0,0,0,.28),transparent 55%),var(--bg);font-family:Georgia,"Times New Roman",serif}.bh-top{min-height:72px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:12px 24px;border-bottom:1px solid color-mix(in srgb,var(--bh-accent) 45%,var(--border));background:color-mix(in srgb,var(--panel) 94%,transparent);position:sticky;top:0;z-index:5}.bh-back{color:var(--muted);text-decoration:none}.bh-brand{text-align:center}.bh-brand b{display:block;color:var(--bh-accent);font:700 1.25rem/1 "Courier New",monospace;letter-spacing:.2em;text-shadow:0 0 14px var(--bh-glow)}.bh-brand span,.bh-kicker{font:700 .66rem/1.3 "Courier New",monospace;letter-spacing:.13em;color:var(--muted)}.bh-top .mf-a11y-host{justify-self:end}.bh-view{max-width:1240px;margin:auto;padding:2rem 1.25rem}.bh-loading{text-align:center;padding:20vh 0;color:var(--muted);font-family:"Courier New",monospace}.bh-hero,.bh-briefing,.bh-debrief,.bh-result{position:relative;border:1px solid color-mix(in srgb,var(--bh-accent) 55%,var(--border));border-radius:14px;background:linear-gradient(110deg,color-mix(in srgb,var(--surface) 95%,transparent),var(--surface));padding:1.6rem;box-shadow:0 12px 34px rgba(0,0,0,.18)}.bh-hero:before,.bh-briefing:before,.bh-debrief:before{content:"";position:absolute;inset:8px;border:1px solid color-mix(in srgb,var(--bh-accent) 26%,transparent);pointer-events:none}.bh-hero h1,.bh-briefing h1,.bh-hunt-head h1,.bh-debrief h1,.bh-result h1{color:var(--heading);margin:.45rem 0}.bh-hero p,.bh-briefing p,.bh-case p,.bh-debrief p{line-height:1.6;color:var(--muted);max-width:850px}.bh-stamp{display:inline-block;border:2px solid var(--bh-accent);color:var(--bh-accent);font:700 .7rem/1 "Courier New",monospace;letter-spacing:.12em;padding:.28rem .42rem;transform:rotate(-3deg);margin:0 .65rem .4rem 0}.bh-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-top:1rem}.bh-case{border:1px solid var(--border);border-radius:10px;background:var(--surface);padding:1.1rem;box-shadow:4px 5px 0 color-mix(in srgb,var(--bh-accent) 16%,transparent)}.bh-case h2{color:var(--heading);margin:.4rem 0}.bh-case-no{color:var(--bh-accent);font:700 .68rem "Courier New",monospace;letter-spacing:.08em}.bh-tags{display:flex;gap:.4rem;flex-wrap:wrap;margin:.9rem 0}.bh-tags span{border:1px solid var(--border);border-radius:99px;padding:.22rem .5rem;font:700 .65rem "Courier New",monospace;color:var(--muted)}.bh-btn{border:1px solid var(--bh-accent);background:transparent;border-radius:6px;padding:.65rem .85rem;color:var(--bh-accent);font:700 .72rem "Courier New",monospace;letter-spacing:.06em;cursor:pointer}.bh-btn:hover,.bh-btn.primary{background:var(--bh-accent);color:#19090e;box-shadow:0 0 16px var(--bh-glow)}.bh-briefing pre,.bh-evidence pre{white-space:pre-wrap;background:#0d0c0f;color:#d8cbd0;border-left:3px solid var(--bh-accent);padding:.8rem;font:normal .78rem/1.5 "Courier New",monospace}.bh-rules{display:flex;gap:.7rem;flex-wrap:wrap;margin:1rem 0}.bh-rules div{background:var(--surface-2);padding:.65rem;border-radius:7px;color:var(--muted)}.bh-rules b{display:inline-grid;place-items:center;width:1.4rem;height:1.4rem;border-radius:50%;background:var(--bh-accent);color:#210c12;margin-right:.35rem}.bh-hunt-head{display:flex;justify-content:space-between;gap:1rem;align-items:center}.bh-combo{border:1px solid var(--bh-accent);border-radius:50%;width:68px;height:68px;display:grid;place-content:center;text-align:center;color:var(--bh-accent);font:700 1.25rem "Courier New",monospace}.bh-combo small{font-size:.46rem;text-transform:uppercase;color:var(--muted)}.bh-progress{display:flex;gap:.45rem;margin:1rem 0}.bh-progress i{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--border);font:700 .75rem "Courier New",monospace;color:var(--muted);font-style:normal}.bh-progress i.current{border-color:var(--bh-accent);color:var(--bh-accent);box-shadow:0 0 10px var(--bh-glow)}.bh-progress i.done{background:var(--success);border-color:var(--success);color:#08131a}.bh-layout{display:grid;grid-template-columns:3fr 2fr;gap:1rem}.bh-code-area,.bh-side>*,.bh-action{border:1px solid var(--border);border-radius:10px;background:var(--surface)}.bh-tabs{display:flex;overflow:auto;border-bottom:1px solid var(--border)}.bh-tabs button{border:0;border-right:1px solid var(--border);background:transparent;padding:.7rem .9rem;color:var(--muted);font:700 .73rem "Courier New",monospace;cursor:pointer}.bh-tabs button.active{background:color-mix(in srgb,var(--bh-accent) 14%,transparent);color:var(--bh-accent)}.bh-code{padding:.55rem 0;overflow:auto;max-height:65vh;background:#0a090b}.bh-line{width:100%;min-width:max-content;display:grid;grid-template-columns:52px 1fr;gap:.8rem;text-align:left;border:0;border-left:3px solid transparent;padding:.1rem .8rem;background:transparent;color:#dad5d7;font:normal .83rem/1.55 "Courier New",monospace}.bh-line:not(:disabled){cursor:pointer}.bh-line:not(:disabled):hover,.bh-line.selected{background:color-mix(in srgb,var(--bh-accent) 17%,transparent);border-left-color:var(--bh-accent)}.bh-line span{text-align:right;color:#776e72;user-select:none}.bh-side{display:grid;gap:1rem;align-content:start}.bh-evidence,.bh-action{padding:1rem}.bh-evidence p{line-height:1.5}.bh-action h2{color:var(--heading);margin:.4rem 0}.bh-action p{color:var(--muted);line-height:1.45}.bh-selected{font-family:"Courier New",monospace;color:var(--bh-accent)!important}.bh-options{display:grid;gap:.45rem;margin:.8rem 0}.bh-options button{border:1px solid var(--border);background:var(--surface-2);border-radius:6px;padding:.65rem;color:var(--text);text-align:left;cursor:pointer}.bh-options button.selected{border-color:var(--bh-accent);color:var(--bh-accent);box-shadow:inset 0 0 0 1px var(--bh-accent)}.bh-attempts{font:normal .75rem "Courier New",monospace!important}.bh-feedback{min-height:1.4rem;font:700 .8rem "Courier New",monospace}.bh-feedback.ok{color:var(--success)}.bh-feedback.bad{color:var(--danger)}.bh-actions{display:flex;gap:.55rem;flex-wrap:wrap;margin-top:.6rem}.bh-debrief,.bh-result{text-align:center;max-width:780px;margin:7vh auto}.bh-diff{display:grid;gap:.35rem;text-align:left;background:#0b0a0c;padding:1rem;margin:1rem 0;font:normal .8rem "Courier New",monospace}.bh-diff span:first-child{color:var(--danger)}.bh-diff span:nth-of-type(2){color:var(--success)}.bh-diff code{color:#ded7db}.bh-score{font:700 3.5rem "Courier New",monospace;color:var(--bh-accent);text-shadow:0 0 20px var(--bh-glow)}.bh-stars{color:var(--bh-accent);font-size:1.5rem}.bh-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:.7rem;margin:1rem 0}.bh-stats div{padding:.75rem;background:var(--surface-2);border-radius:7px;color:var(--muted);font-size:.8rem}.bh-stats b{display:block;color:var(--heading);font:700 1.1rem "Courier New",monospace;margin-bottom:.2rem}@media(max-width:850px){.bh-grid{grid-template-columns:1fr 1fr}.bh-layout{grid-template-columns:1fr}.bh-code{max-height:45vh}}@media(max-width:560px){.bh-top{grid-template-columns:1fr auto}.bh-brand{display:none}.bh-grid{grid-template-columns:1fr}.bh-stats{grid-template-columns:1fr 1fr}.bh-view{padding:1.1rem .75rem}.bh-line{grid-template-columns:38px 1fr;font-size:.74rem}.bh-hunt-head h1{font-size:1.35rem}} +.bh-app{--accent:var(--bh-accent);--glow-s:var(--bh-glow);min-height:100vh;color:var(--text);background:radial-gradient(circle at 10% 0,color-mix(in srgb,var(--bh-accent) 14%,transparent),transparent 33%),linear-gradient(135deg,rgba(0,0,0,.28),transparent 55%),var(--bg);font-family:Georgia,"Times New Roman",serif}.bh-top{min-height:72px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:12px 24px;border-bottom:1px solid color-mix(in srgb,var(--bh-accent) 45%,var(--border));background:color-mix(in srgb,var(--panel) 94%,transparent);position:sticky;top:0;z-index:5}.bh-back{color:var(--muted);text-decoration:none}.bh-brand{text-align:center}.bh-brand b{display:block;color:var(--bh-accent);font:700 1.25rem/1 "Courier New",monospace;letter-spacing:.2em;text-shadow:0 0 14px var(--bh-glow)}.bh-brand span,.bh-kicker{font:700 .66rem/1.3 "Courier New",monospace;letter-spacing:.13em;color:var(--muted)}.bh-top .mf-a11y-host{justify-self:end}.bh-view{max-width:1240px;margin:auto;padding:2rem 1.25rem}.bh-loading{text-align:center;padding:20vh 0;color:var(--muted);font-family:"Courier New",monospace}.bh-hero,.bh-briefing,.bh-debrief,.bh-result{position:relative;border:1px solid color-mix(in srgb,var(--bh-accent) 55%,var(--border));border-radius:14px;background:linear-gradient(110deg,color-mix(in srgb,var(--surface) 95%,transparent),var(--surface));padding:1.6rem;box-shadow:0 12px 34px rgba(0,0,0,.18)}.bh-hero:before,.bh-briefing:before,.bh-debrief:before{content:"";position:absolute;inset:8px;border:1px solid color-mix(in srgb,var(--bh-accent) 26%,transparent);pointer-events:none}.bh-hero h1,.bh-briefing h1,.bh-hunt-head h1,.bh-debrief h1,.bh-result h1{color:var(--heading);margin:.45rem 0}.bh-hero p,.bh-briefing p,.bh-case p,.bh-debrief p{line-height:1.6;color:var(--muted);max-width:850px}.bh-stamp{display:inline-block;border:2px solid var(--bh-accent);color:var(--bh-accent);font:700 .7rem/1 "Courier New",monospace;letter-spacing:.12em;padding:.28rem .42rem;transform:rotate(-3deg);margin:0 .65rem .4rem 0}.bh-archive{margin-top:1.2rem}.bh-archive-head{display:flex;justify-content:space-between;align-items:end;gap:1rem;padding:0 .5rem}.bh-archive-head h2{margin:.3rem 0;color:var(--heading)}.bh-archive-head p{margin:0;color:var(--muted);font-size:.9rem}.bh-case-deck{position:relative;min-height:540px;margin-top:.8rem}.bh-case{position:absolute;inset:0 auto auto 50%;width:min(680px,92%);min-height:260px;transform:translateX(-50%) translateY(calc(var(--stack) * 25px)) rotate(var(--tilt));z-index:calc(30 - var(--stack));border:1px solid var(--border);border-radius:10px;background:linear-gradient(120deg,var(--surface),color-mix(in srgb,var(--bh-accent) 6%,var(--surface)));padding:1.15rem 1.25rem;box-shadow:4px 6px 0 color-mix(in srgb,var(--bh-accent) 16%,transparent),0 10px 26px rgba(0,0,0,.2);cursor:pointer;transition:transform .25s ease,box-shadow .25s ease,border-color .25s ease}.bh-case:hover,.bh-case:focus-visible{border-color:var(--bh-accent);outline:none}.bh-case.active{min-height:355px;border-color:color-mix(in srgb,var(--bh-accent) 70%,var(--border));box-shadow:7px 9px 0 color-mix(in srgb,var(--bh-accent) 22%,transparent),0 16px 40px rgba(0,0,0,.32)}.bh-case h2{color:var(--heading);margin:.4rem 0}.bh-case-no{color:var(--bh-accent);font:700 .68rem "Courier New",monospace;letter-spacing:.08em}.bh-case-peek{display:block;margin-top:5.5rem;color:var(--muted);font:italic .88rem Georgia,serif}.bh-tags{display:flex;gap:.4rem;flex-wrap:wrap;margin:.9rem 0}.bh-tags span{border:1px solid var(--border);border-radius:99px;padding:.22rem .5rem;font:700 .65rem "Courier New",monospace;color:var(--muted)}.bh-btn{border:1px solid var(--bh-accent);background:transparent;border-radius:6px;padding:.65rem .85rem;color:var(--bh-accent);font:700 .72rem "Courier New",monospace;letter-spacing:.06em;cursor:pointer}.bh-btn:hover,.bh-btn.primary{background:var(--bh-accent);color:#19090e;box-shadow:0 0 16px var(--bh-glow)}.bh-briefing pre,.bh-evidence pre{white-space:pre-wrap;background:#0d0c0f;color:#d8cbd0;border-left:3px solid var(--bh-accent);padding:.8rem;font:normal .78rem/1.5 "Courier New",monospace}.bh-rules{display:flex;gap:.7rem;flex-wrap:wrap;margin:1rem 0}.bh-rules div{background:var(--surface-2);padding:.65rem;border-radius:7px;color:var(--muted)}.bh-rules b{display:inline-grid;place-items:center;width:1.4rem;height:1.4rem;border-radius:50%;background:var(--bh-accent);color:#210c12;margin-right:.35rem}.bh-hunt-head{display:flex;justify-content:space-between;gap:1rem;align-items:center}.bh-combo{border:1px solid var(--bh-accent);border-radius:50%;width:68px;height:68px;display:grid;place-content:center;text-align:center;color:var(--bh-accent);font:700 1.25rem "Courier New",monospace}.bh-combo small{font-size:.46rem;text-transform:uppercase;color:var(--muted)}.bh-progress{display:flex;gap:.45rem;margin:1rem 0}.bh-progress i{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--border);font:700 .75rem "Courier New",monospace;color:var(--muted);font-style:normal}.bh-progress i.current{border-color:var(--bh-accent);color:var(--bh-accent);box-shadow:0 0 10px var(--bh-glow)}.bh-progress i.done{background:var(--success);border-color:var(--success);color:#08131a}.bh-layout{display:grid;grid-template-columns:3fr 2fr;gap:1rem}.bh-code-area,.bh-side>*,.bh-action{border:1px solid var(--border);border-radius:10px;background:var(--surface)}.bh-tabs{display:flex;overflow:auto;border-bottom:1px solid var(--border)}.bh-tabs button{border:0;border-right:1px solid var(--border);background:transparent;padding:.7rem .9rem;color:var(--muted);font:700 .73rem "Courier New",monospace;cursor:pointer}.bh-tabs button.active{background:color-mix(in srgb,var(--bh-accent) 14%,transparent);color:var(--bh-accent)}.bh-code{padding:.55rem 0;overflow:auto;max-height:65vh;background:#0a090b}.bh-line{width:100%;min-width:max-content;display:grid;grid-template-columns:52px 1fr;gap:.8rem;text-align:left;border:0;border-left:3px solid transparent;padding:.1rem .8rem;background:transparent;color:#dad5d7;font:normal .83rem/1.55 "Courier New",monospace}.bh-line:not(:disabled){cursor:pointer}.bh-line:not(:disabled):hover,.bh-line.selected{background:color-mix(in srgb,var(--bh-accent) 17%,transparent);border-left-color:var(--bh-accent)}.bh-line span{text-align:right;color:#776e72;user-select:none}.bh-side{display:grid;gap:1rem;align-content:start}.bh-evidence,.bh-action{padding:1rem}.bh-evidence p{line-height:1.5}.bh-action h2{color:var(--heading);margin:.4rem 0}.bh-action p{color:var(--muted);line-height:1.45}.bh-handoff{border-color:var(--bh-accent);box-shadow:0 0 22px color-mix(in srgb,var(--bh-accent) 15%,transparent)}.bh-selected{font-family:"Courier New",monospace;color:var(--bh-accent)!important}.bh-options{display:grid;gap:.45rem;margin:.8rem 0}.bh-options button{border:1px solid var(--border);background:var(--surface-2);border-radius:6px;padding:.65rem;color:var(--text);text-align:left;cursor:pointer}.bh-options button.selected{border-color:var(--bh-accent);color:var(--bh-accent);box-shadow:inset 0 0 0 1px var(--bh-accent)}.bh-attempts{font:normal .75rem "Courier New",monospace!important}.bh-feedback{min-height:1.4rem;font:700 .8rem "Courier New",monospace}.bh-feedback.ok{color:var(--success)}.bh-feedback.bad{color:var(--danger)}.bh-actions{display:flex;gap:.55rem;flex-wrap:wrap;margin-top:.6rem}.bh-debrief,.bh-result{text-align:center;max-width:780px;margin:7vh auto}.bh-diff{display:grid;gap:.35rem;text-align:left;background:#0b0a0c;padding:1rem;margin:1rem 0;font:normal .8rem "Courier New",monospace}.bh-diff span:first-child{color:var(--danger)}.bh-diff span:nth-of-type(2){color:var(--success)}.bh-diff code{color:#ded7db}.bh-score{font:700 3.5rem "Courier New",monospace;color:var(--bh-accent);text-shadow:0 0 20px var(--bh-glow)}.bh-stars{color:var(--bh-accent);font-size:1.5rem}.bh-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:.7rem;margin:1rem 0}.bh-stats div{padding:.75rem;background:var(--surface-2);border-radius:7px;color:var(--muted);font-size:.8rem}.bh-stats b{display:block;color:var(--heading);font:700 1.1rem "Courier New",monospace;margin-bottom:.2rem}@media(max-width:850px){.bh-layout{grid-template-columns:1fr}.bh-code{max-height:45vh}.bh-case-deck{min-height:590px}}@media(max-width:560px){.bh-top{grid-template-columns:1fr auto}.bh-brand{display:none}.bh-stats{grid-template-columns:1fr 1fr}.bh-view{padding:1.1rem .75rem}.bh-line{grid-template-columns:38px 1fr;font-size:.74rem}.bh-hunt-head h1{font-size:1.35rem}.bh-archive-head{display:block}.bh-archive-head p{margin-top:.5rem}.bh-case-deck{min-height:auto;display:grid;gap:.7rem}.bh-case{position:relative;inset:auto;width:100%;min-height:0;transform:none!important}.bh-case.active{min-height:0}.bh-case-peek{margin-top:.5rem}} diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css index 69552f7..dee0936 100644 --- a/src/main/resources/static/css/theme.css +++ b/src/main/resources/static/css/theme.css @@ -126,8 +126,8 @@ a { color:var(--secondary); } .mf-card-codeescape { --accent:var(--esc-accent); --glow-s:var(--esc-glow); --glow-w:color-mix(in srgb,var(--esc-accent) 16%,transparent); background:repeating-linear-gradient(0deg,rgba(255,255,255,.015) 0 1px,transparent 1px 4px),var(--surface); } .mf-card-codeescape::after { content:"ACCESS"; position:absolute; right:12px; top:9px; color:var(--esc-accent); font:700 .52rem/1 "Courier New",monospace; letter-spacing:.12em; opacity:.6; } -.mf-card-bughunter { --accent:var(--bh-accent); --glow-s:var(--bh-glow); --glow-w:color-mix(in srgb,var(--bh-accent) 16%,transparent); background:linear-gradient(135deg,color-mix(in srgb,var(--bh-accent) 9%,transparent),var(--surface)); } -.mf-card-bughunter::after { content:"CASE FILE"; position:absolute; right:12px; top:9px; color:var(--bh-accent); font:700 .52rem/1 "Courier New",monospace; letter-spacing:.12em; opacity:.7; } +.mf-card-bughunter { --accent:var(--esc-accent); --glow-s:var(--esc-glow); --glow-w:color-mix(in srgb,var(--esc-accent) 16%,transparent); background:linear-gradient(135deg,color-mix(in srgb,var(--esc-accent) 8%,transparent),var(--surface)); } +.mf-card-bughunter::after { content:"FALLAKTE"; position:absolute; right:12px; top:9px; color:var(--esc-accent); font:700 .52rem/1 "Courier New",monospace; letter-spacing:.12em; opacity:.7; } /* ===== Panels / Widgets ===== */ .mf-panel { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:1.1rem; box-shadow:var(--g-soft); } diff --git a/src/main/resources/static/js/bughunter.js b/src/main/resources/static/js/bughunter.js index de4f435..79fedd6 100644 --- a/src/main/resources/static/js/bughunter.js +++ b/src/main/resources/static/js/bughunter.js @@ -1,7 +1,7 @@ (function () { 'use strict'; const view = document.getElementById('bhView'); - let catalog = [], session, index = 0, started = 0, selectedFile = '', selectedLine = 0, selected = '', options = [], answers = []; + let catalog = [], selectedCase = 0, session, index = 0, started = 0, selectedFile = '', selectedLine = 0, selected = '', options = [], answers = [], handoff = null; const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const api = (url, opt) => fetch('/api/bughunter' + url, {headers:{'Content-Type':'application/json'}, ...opt}).then(async response => { const body = await response.json(); if (!response.ok) throw new Error(body.message || 'Serverfehler'); return body; }); const post = (url, body) => api(url, {method:'POST', body:JSON.stringify(body)}); @@ -10,10 +10,15 @@ const state = () => answers[index]; function loadCatalog() { api('/hunts').then(data => { catalog = data.hunts || []; renderCatalog(); }).catch(error); } function renderCatalog() { - view.innerHTML = `
VERTRAULICDETektivbüro · DEBUGGING-FALLAKTEN

Welche Spur verfolgst du?

Arbeite dich durch echte Fehlerbilder: Ort, Kategorie und Fix müssen zusammenpassen. Präzision baut deine Ermittlungsserie auf.

${catalog.map(h => `
${esc(h.examScope)} · ${esc(h.difficulty).toUpperCase()}

${esc(h.title)}

${esc(h.description)}

${esc(h.language)}${h.fileCount} Dateien${h.bugCount} Bug${h.bugCount === 1 ? '' : 's'}
`).join('')}
`; - view.querySelectorAll('[data-start]').forEach(button => button.addEventListener('click', () => begin(button.dataset.start))); + if (!catalog.length) { error(new Error('Zurzeit sind keine Fallakten verfügbar.')); return; } + selectedCase = Math.max(0, Math.min(selectedCase, catalog.length - 1)); + const selectedHunt = catalog[selectedCase]; + const deck = [selectedHunt, ...catalog.filter((_, i) => i !== selectedCase)]; + view.innerHTML = `
VERTRAULICHDEBUG-FALLAKTEN

Welche Spur verfolgst du?

Öffne eine Akte, lies die Beweise und sichere deinen Befund Schritt für Schritt. Jede Akte bleibt so lange offen, wie du sie brauchst.

FALLARCHIV · ${catalog.length} AKTEN

${esc(selectedHunt.title)}

Klicke eine Akte an, um sie nach vorn zu holen.

${deck.map((h, stackIndex) => { const originalIndex = catalog.indexOf(h), active = originalIndex === selectedCase; return `
${esc(h.examScope)} · ${esc(h.difficulty).toUpperCase()}

${esc(h.title)}

${active ? `

${esc(h.description)}

${esc(h.language)}${h.fileCount} ${h.fileCount === 1 ? 'Datei' : 'Dateien'}${h.bugCount} Bug${h.bugCount === 1 ? '' : 's'}
` : 'Zum Öffnen nach vorn holen'}
`; }).join('')}
`; + view.querySelectorAll('[data-select]').forEach(card => { const select = () => { selectedCase = Number(card.dataset.select); renderCatalog(); }; card.addEventListener('click', event => { if (!event.target.closest('[data-start]')) select(); }); card.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); select(); } }); }); + view.querySelector('[data-start]')?.addEventListener('click', event => { event.stopPropagation(); begin(event.currentTarget.dataset.start); }); } - function begin(slug) { api('/session/' + encodeURIComponent(slug)).then(data => { if (!data.found) throw new Error('Fallakte nicht verfügbar.'); session = data; index = 0; answers = session.bugs.map(() => ({locate:null, classify:null, fix:null})); started = Date.now(); renderBriefing(); }).catch(error); } + function begin(slug) { api('/session/' + encodeURIComponent(slug)).then(data => { if (!data.found) throw new Error('Fallakte nicht verfügbar.'); session = data; index = 0; handoff = null; answers = session.bugs.map(() => ({locate:null, classify:null, fix:null})); started = Date.now(); renderBriefing(); }).catch(error); } function renderBriefing() { view.innerHTML = `
NEUE AKTE${esc(session.examScope)} · ${esc(session.difficulty).toUpperCase()} · ${esc(session.language)}

${esc(session.title)}

${esc(session.briefing)}

${esc(session.consoleOutput)}
1 Ort markieren
2 Fehler einordnen
3 sauberen Fix wählen
`; document.getElementById('bhStart').addEventListener('click', () => { selectedFile = session.files[0]?.filename || ''; selectedLine = 0; selected = ''; options = []; renderHunt(); }); @@ -32,6 +37,7 @@ return `
${file.lines.map((line, i) => { const lineNo = i + 1, marked = phase === 'locate' && selectedLine === lineNo; return ``; }).join('')}
`; } function renderAction(phase, bug, s) { + if (handoff) return `
PHASE ABGESCHLOSSEN

${esc(handoff.title)}

${esc(handoff.message)}

`; const labels = {locate:'1 · ORTEN', classify:'2 · KLASSIFIZIEREN', fix:'3 · FIXEN'}; let content = ''; if (phase === 'locate') content = `

Klicke die fehlerhafte Zeile im Code an.

${selectedLine ? `${esc(selectedFile)}:${selectedLine}` : 'Noch keine Zeile markiert'}

`; @@ -41,6 +47,7 @@ return `
${labels[phase]}

${phase === 'locate' ? 'Wo liegt der Bug?' : phase === 'classify' ? 'Wie heißt das Problem?' : 'Wie wird er behoben?'}

${content}

Versuche: ${attempts}/3

`; } function bindAction(phase, bug) { + if (handoff) { document.getElementById('bhContinue').addEventListener('click', () => { handoff = null; selected = ''; selectedLine = 0; renderHunt(); }); return; } if (phase === 'locate') view.querySelectorAll('[data-line]').forEach(line => line.addEventListener('click', () => { selectedLine = Number(line.dataset.line); renderHunt(); })); view.querySelectorAll('[data-choice]').forEach(button => button.addEventListener('click', () => { selected = button.dataset.choice; view.querySelectorAll('[data-choice]').forEach(item => item.classList.remove('selected')); button.classList.add('selected'); })); document.getElementById('bhCheck').addEventListener('click', () => submit(phase, bug, false)); @@ -53,9 +60,16 @@ post('/check', {bugId:bug.bugId, phase, reveal, submission:sub}).then(result => { if (!result.resolved) { state()[phase] = phaseState; feedback('Noch nicht stimmig. Lies die Beweise erneut.', 'bad'); return; } phaseState.revealed = reveal; phaseState.done = true; state()[phase] = phaseState; - if (phase === 'classify') options = result.fixOptions || []; if (phase === 'fix') { showDebrief(result); return; } - selected = ''; if (phase === 'locate') selectedLine = result.location?.line || selectedLine; - feedback(reveal ? 'Auflösung notiert – weiter zur nächsten Phase.' : 'Spur bestätigt – weiter zur nächsten Phase.', 'ok'); setTimeout(renderHunt, 650); + if (phase === 'classify') options = result.fixOptions || []; + if (phase === 'fix') { showDebrief(result); return; } + if (phase === 'locate') { + const location = result.location || sub; + handoff = { title: reveal ? 'Auflösung: Fundort markiert' : 'Fundort bestätigt', message: `Die relevante Stelle ist ${location.file}:${location.line}. Nimm dir einen Moment für den Code, bevor du die Fehlerklasse bestimmst.`, nextLabel: 'ZUR KATEGORIE' }; + } else { + const category = result.category || sub.category; + handoff = { title: reveal ? 'Auflösung: Fehlerklasse' : 'Fehlerklasse bestätigt', message: `Der Fehler gehört zur Kategorie „${category}“. Die möglichen Korrekturen liegen jetzt bereit.`, nextLabel: 'ZUR KORREKTUR' }; + } + selected = ''; renderHunt(); }).catch(err => feedback(err.message, 'bad')); } function showDebrief(result) { diff --git a/src/main/resources/templates/bughunter.html b/src/main/resources/templates/bughunter.html index 6168d11..d9d9f30 100644 --- a/src/main/resources/templates/bughunter.html +++ b/src/main/resources/templates/bughunter.html @@ -1 +1 @@ -BugHunter · Mindflux
← Dashboard
BUGHUNTERINVESTIGATION BUREAU
FALLAKTEN WERDEN GEÖFFNET …
+BugHunter · Mindflux
← Dashboard
BUGHUNTERDEBUG-FALLAKTEN
FALLAKTEN WERDEN GEÖFFNET …