Mindflux: Sicherheit, Datenintegrität und Navigation härten

This commit is contained in:
AliceDarkest
2026-07-12 21:47:37 +02:00
parent db6b5bbb1a
commit 005d2d7d8c
73 changed files with 2495 additions and 650 deletions
+7
View File
@@ -22,8 +22,15 @@ jobs:
- name: Deploy - name: Deploy
env: env:
MINDFLUX_DB_PASSWORD: ${{ secrets.MINDFLUX_DB_PASSWORD }}
MINDFLUX_ADMIN_PASSWORD: ${{ secrets.MINDFLUX_ADMIN_PASSWORD }} MINDFLUX_ADMIN_PASSWORD: ${{ secrets.MINDFLUX_ADMIN_PASSWORD }}
MINDFLUX_ADMIN2_PASSWORD: ${{ secrets.MINDFLUX_ADMIN2_PASSWORD }} MINDFLUX_ADMIN2_PASSWORD: ${{ secrets.MINDFLUX_ADMIN2_PASSWORD }}
run: | run: |
test -n "$MINDFLUX_DB_PASSWORD" || { echo "MINDFLUX_DB_PASSWORD fehlt" >&2; exit 1; }
docker exec -i -e NEW_DB_PASSWORD="$MINDFLUX_DB_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U mindflux -d mindfluxdb <<'SQL'
\getenv new_db_password NEW_DB_PASSWORD
ALTER USER mindflux WITH PASSWORD :'new_db_password';
SQL
docker compose -f docker-compose.yml pull docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d docker compose -f docker-compose.yml up -d
+82
View File
@@ -4,6 +4,88 @@ Alle nennenswerten Änderungen an **Mindflux** werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
Versionierung [semantisch](https://semver.org/lang/de/). Versionierung [semantisch](https://semver.org/lang/de/).
## [Unreleased] – 2026-07-12 · Großer Verbesserungs-Sweep (Sicherheit, Daten, Navigation)
Weitgehende Umsetzung des Verbesserungskatalogs aus `mindfluxfuture.md` (Teil 2;
bewusst zurückgestellte Restpunkte sind dort weiterhin als offen markiert):
Sicherheit, Datenintegrität, echtes XP/Achievement-System, Navigations-Umbau,
Docker/CI-Härtung und erste Tests.
### Sicherheit
- **DB-Passwort aus dem Repo entfernt:** `docker-compose.yml` liest jetzt `${MINDFLUX_DB_PASSWORD}`
aus einem Gitea-Secret (wie die Admin-Passwörter).
Compose bricht bei einem fehlenden oder leeren Secret jetzt sofort mit einer eindeutigen
Fehlermeldung ab, statt einen App-Container mit leerem DB-Passwort zu starten.
Der Gitea-Runner gleicht das PostgreSQL-Passwort vor jedem Deployment automatisch mit dem
Secret ab; für die Rotation ist daher kein separater SSH-Zugang erforderlich.
**Produktionsrotation abgeschlossen am 2026-07-12:** Secret angelegt, DB-Rolle aktualisiert
und Mindflux über den erfolgreichen Gitea-Workflow-Lauf 43 neu gestartet (Commit `4f5042d`).
- **Unsicherer Legacy-Endpunkt `/users/save` gelöscht** (speicherte User ohne Hashing, mit frei
wählbarer Rolle) inkl. `UserViewController`, `user-form.html`, `user-success.html`.
- **Logout nur noch per POST** mit CSRF-Token (alle Templates umgestellt).
- **Login-Brute-Force-Schutz:** Nach 5 Fehlversuchen wird der Benutzername 15 Minuten gesperrt
(`LoginAttemptService`, In-Memory).
- **Registrierung gehärtet:** Passwort-Mindestlänge 8, E-Mail-Formatprüfung, case-insensitiver
Unique-Index auf `users.username` (Migration V9) schließt das Registrierungs-Race.
- **Bootstrap-Admins abgesichert:** Ein Admin wird nur noch angelegt, wenn sein Passwort-Secret
tatsächlich gesetzt ist; fehlende optionale Admin-Konfiguration wird protokolliert und sicher
übersprungen.
- Dockerfile läuft nicht mehr als root (`USER mindflux`), zweistufig mit Dependency-Cache;
Tests laufen im Image-Build mit. Compose-Healthcheck auf `/actuator/health` (Actuator neu).
### Daten & Backend
- **Migration V10:** `UNIQUE(lower(email))`, Fremdschlüssel von `game_scores`/`achievements`
auf `users(username)` mit `ON DELETE CASCADE ON UPDATE CASCADE`; verwaiste Altdaten werden
bereinigt, E-Mail-Dubletten aufgelöst (älteste Zeile behält die Adresse).
- **Echte XP/Level/Streak statt Dummy-Daten:** `ProgressService` aggregiert aus `game_scores`
(XP = Score-Summe, 1000 XP je Level, Tages-Streak mit 1 Tag Karenz); Dashboard-Sidebar zeigt
echte Werte.
- **Achievements sind live:** Katalog aus 6 Regeln (Erster Sieg, 1000er-Club, Highscore-Jäger,
Dauerbrenner, Punktesammler, Allrounder), Vergabe automatisch bei jedem gespeicherten Score;
Dummy-Daten entfernt, gesperrte Karten zeigen den Katalog.
- **User-Logik konsolidiert:** Registrierung, REST- und Admin-Anlage laufen über einen
`UserService` (eine Stelle für BCrypt, Rollen-Whitelist, Eindeutigkeit);
`AdminUserService`/`AdminUserRepository` (Duplikate) entfernt.
- **Globales Error-Handling:** eigene Fehlerseite (`error.html`) statt Whitelabel, JSON-Fehler
mit korrekten Statuscodes für alle APIs, unbekannte URLs liefern eine echte 404-Seite.
- **N+1-Queries behoben:** TechTacular-Board lädt Kategorien/Fragen/Antworten in 2 Queries
(Fetch-Join), Admin-Fragenzähler als eine GROUP-BY-Query.
- **Logging:** alle `System.out`/`printStackTrace` durch SLF4J ersetzt.
- `spring-boot-starter-validation` + `-actuator` ergänzt, redundantes `spring-security-crypto`
entfernt, Java auf 21 vereinheitlicht (pom + Dockerfile).
### Navigation & UI
- **Layout-Fragmente eingeführt** (`fragments/layout.html`): App-Sidebar, Profil-Tab-Leiste und
Footer sind jetzt gemeinsame Fragmente (erster Schritt gegen die Template-Duplikation).
- **Sidebar hat einen „Profil"-Eintrag**; der User-Block verlor den irreführenden Dropdown-Pfeil.
- **Profil-Tab-Leiste auf allen Unterseiten** (Profil · Statistiken · Erfolge · Soziales ·
Einstellungen) mit einheitlichen Namen; Rück-Links führen einheitlich zum Dashboard.
- **Einstellungen aufgeräumt:** Theme-Auswahl funktioniert jetzt wirklich (gekoppelt an
`theme.js`/localStorage); funktionslose Sprach-/Benachrichtigungs-Attrappen entfernt;
`SettingsController` in den `ProfileController` gemergt; Token-Farbe statt Hardcode.
- **Spielnamen vereinheitlicht** (englisch, zusammengeschrieben): DevLab, SyntaxSaga sowie
CodeEscape, ServiceClash, BugHunter, NetWorker für die geplanten Spiele (Keys in
`KNOWN_GAMES`/`ProgressService` mit umbenannt – noch keine Scores vorhanden).
- **DevLab erscheint jetzt auf der Statistik-Seite** (fehlte in `ProgressService.GAMES`).
- Tote Templates entfernt: `login.html`, `register.html` (Formular lebt auf der Startseite),
`AppStartCheck` u. a. Kleinkram.
### FinalFlux
- **AP2-Gerüst:** neue Sidebar-Blöcke „AP2 Anwendungsentwicklung" und „AP2 Systemintegration"
mit den ersten Themenseiten **Entwurfsmuster** (FIAE) und **Hochverfügbarkeit** (FISI) inkl.
FluxChecks – FinalFlux deckt damit den Weg zur gesamten Ausbildung ab (AP1 + AP2).
### Tests (neu – vorher 0)
- 33 Tests: Score-Formel, XP/Level/Streak-Berechnung, Registrierungsregeln, Achievement-Vergabe,
Login-Sperre sowie `@WebMvcTest` der Security-Regeln (Login-Zwang, Admin-Schutz,
gelöschter Legacy-Endpunkt). Die CI baut das Image jetzt inklusive Testlauf.
### Dokumentation
- **`mindfluxfuture.md`:** konsolidierter Verbesserungs- und Ausbaukatalog mit umgesetzten,
bewusst zurückgestellten und künftig geplanten Punkten.
- **`pw_anleitung.md`:** Betriebsanleitung für DB-Secret, automatische Runner-Rotation,
manuellen Notfallweg und Verifikation nach dem Deployment.
## [Unreleased] – 2026-06-30 · Login, Registrierung & Konten ## [Unreleased] – 2026-06-30 · Login, Registrierung & Konten
Reihe von Fixes und Verbesserungen rund um Anmeldung, Registrierung und Reihe von Fixes und Verbesserungen rund um Anmeldung, Registrierung und
+19 -2
View File
@@ -3,17 +3,34 @@ FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app WORKDIR /app
COPY . . # Dependency-Layer cachen: erst pom.xml auflösen, dann erst den Quellcode kopieren.
COPY pom.xml .
RUN mvn -q dependency:go-offline
RUN mvn clean package -DskipTests COPY src ./src
# Tests laufen im Build mit – ein roter Test stoppt das Image (CI-Teststufe inklusive).
RUN mvn -q package
# ---- Runtime Stage ---- # ---- Runtime Stage ----
FROM eclipse-temurin:21-jre FROM eclipse-temurin:21-jre
# curl für den Container-Healthcheck (siehe docker-compose.yml)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Nicht als root laufen; Upload-Verzeichnis für den App-User beschreibbar machen.
RUN useradd --system --home /app --shell /usr/sbin/nologin mindflux \
&& mkdir -p /app/uploads \
&& chown -R mindflux:mindflux /app
COPY --from=build /app/target/*.jar app.jar COPY --from=build /app/target/*.jar app.jar
USER mindflux
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"] ENTRYPOINT ["java","-jar","app.jar"]
+9 -2
View File
@@ -5,10 +5,10 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
# --- Datenbank: unverändert wie im bisherigen Deploy --- # --- Datenbank: Passwort kommt aus Gitea-Secret (siehe Workflow), nie ins Repo ---
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mindfluxdb SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mindfluxdb
SPRING_DATASOURCE_USERNAME: mindflux SPRING_DATASOURCE_USERNAME: mindflux
SPRING_DATASOURCE_PASSWORD: mindflux_pass SPRING_DATASOURCE_PASSWORD: "${MINDFLUX_DB_PASSWORD:?MINDFLUX_DB_PASSWORD muss als Secret gesetzt sein}"
# --- App: lauscht intern auf 8080 (nginx -> mindflux:8080) --- # --- App: lauscht intern auf 8080 (nginx -> mindflux:8080) ---
SERVER_PORT: "8080" SERVER_PORT: "8080"
@@ -23,6 +23,13 @@ services:
MINDFLUX_ADMIN2_EMAIL: "kevcipher@proton.me" MINDFLUX_ADMIN2_EMAIL: "kevcipher@proton.me"
MINDFLUX_ADMIN2_PASSWORD: "${MINDFLUX_ADMIN2_PASSWORD}" MINDFLUX_ADMIN2_PASSWORD: "${MINDFLUX_ADMIN2_PASSWORD}"
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
volumes: volumes:
- mindflux_uploads:/app/uploads - mindflux_uploads:/app/uploads
+506
View File
@@ -0,0 +1,506 @@
# Mindflux – Detailpläne für die 4 geplanten Spiele + Verbesserungsplan
## Kontext
Das Dashboard von Mindflux (`mindflux2`, Spring Boot 3.4.4 / Java 17 / PostgreSQL + Flyway / Thymeleaf / Vanilla-JS) zeigt 9 Spiele. Vier davon sind nur Platzhalter-Karten, die auf `/comingsoon` (`dummy.html`) zeigen und **keinerlei Mechanik, Controller, Tabellen oder Inhalte** besitzen:
1. **Code Escape Room** – „Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever."
2. **Service Clash** – „Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!"
3. **Bug Hunter** – „Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs."
4. **Netzwerker – Das Router-Rätselspiel** – „Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen."
Die Infrastruktur wartet bereits auf sie: Die Spiel-Keys `escape-room`, `service-clash`, `bug-hunter`, `netzwerker` stehen schon in der Score-Allowlist (`GameScoreController.KNOWN_GAMES`, Zeile 23–25) und in `ProgressService.GAMES` (Zeile 23–26); die Dashboard-Icons (`lock`, `sword`, `bug`, `network`) existieren in `dashboard.html`. Ziel dieses Plans: detaillierte, umsetzungsreife Designs für alle vier Spiele nach dem etablierten Architektur-Rezept der vorhandenen Spiele (MindMatch, TechTacular, Syntax Saga, Dev Lab) sowie ein priorisierter Verbesserungs-/Optimierungskatalog für den Rest der Plattform.
**Zielgruppe & inhaltlicher Anspruch: die GESAMTE Fachinformatiker-Ausbildung.** Nicht nur AP1 – abgedeckt werden muss die komplette Ausbildung bis einschließlich **AP2** (beide Fachrichtungen: Anwendungsentwicklung/FIAE und Systemintegration/FISI). Konsequenzen für alle Spiele und Inhalte:
- **Schwierigkeitsstufen = Ausbildungsprogression.** Die in allen vier Designs vorhandenen Stufen (Junior/Senior/Principal, leicht/mittel/schwer, Welten 1–3, Schicht 1–3) werden inhaltlich gemappt: untere Stufen ≈ 1. Lehrjahr/AP1-Stoff, obere Stufen ≈ AP2-Niveau. Kein Schema-Umbau nötig – die `difficulty`-Felder existieren in allen Datenmodellen bereits; es ist eine **Content-Kuratierungs-Regel**.
- **AP2-Content pro Spiel (Startrichtung):** CodeEscape → Szenarien zu Verschlüsselung/Zertifikaten, Virtualisierung/Container, Backup-Strategien; BugHunter → Principal-Fälle auf FIAE-AP2-Niveau (Nebenläufigkeit/Threads, Transaktions-/SQL-Fehler, Design-Pattern-Missbrauch, OWASP-Lücken); ServiceClash → Schichten mit Change-Management, Monitoring/Eskalationsketten, Security-Incidents (FISI-AP2); NetWorker → Welten zu VLAN, statischem Routing über mehrere Hops, Firewall-Regeln und (Ausbau) IPv6/Subnetting-Transfer.
- **Content-Tagging:** Beim Kuratieren jedes Inhalts (Seeder-JSONs, Admin-Eingaben) wird vermerkt, ob er AP1-, AP2/FIAE- oder AP2/FISI-relevant ist – zunächst als Konvention im Titel/`description` (z. B. Präfix „[AP2]"), später optional als eigenes Feld + Filter in den Lobbys („Zeige nur AP2-Stoff").
- **FinalFlux erweitern:** Das bestehende Lernmodul ist rein AP1-fokussiert (28 Seiten + FluxChecks). Die Struktur (statische Themenseiten + Frage-JSONs) trägt problemlos einen **AP2-Ast** – als eigener Sidebar-Block „AP2 Anwendungsentwicklung" / „AP2 Systemintegration" (in Teil 2 als Punkt S10 aufgenommen).
- Die vorhandenen FinalFlux-Inhalte (IP-Berechnung, VLAN, OSI, Wirtschaft) bleiben die AP1-Basis, auf der die Spiele aufsetzen.
**Deliverable:** Dieser Plan liegt als `mindflux2/mindfluxfuture.md` im Repo (neben `STYLEGUIDE.md`/`CHANGELOG.md`).
---
## Namensschema: einheitlich, englisch, zusammengeschrieben
> ✅ **Umgesetzt am 2026-07-12**: Anzeigenamen und Spiel-Keys sind auf das Schema umgestellt (Dashboard-`GAMES`-Array, `KNOWN_GAMES`, `ProgressService`).
Vorgabe: Alle Spielnamen folgen dem MindMatch-Muster – **ein Wort, englisch, CamelCase**. „Netzwerker – Das Router-Rätselspiel" passt nicht ins Schema und wird umbenannt. Neue Namenstabelle (Beschreibungen bleiben deutsch):
| Bisher | Neu (Anzeigename) | Route | Spiel-Key (DB/API) |
|--------|-------------------|-------|--------------------|
| MindMatch | **MindMatch** ✓ | `/mindmatch` | `mindmatch` |
| TechTacular | **TechTacular** ✓ | `/techtacular` | `techtacular` |
| Dev Lab | **DevLab** | `/devlab` | `devlab` |
| Syntax Saga | **SyntaxSaga** | `/syntaxsaga` | `syntax-saga` (Key behalten – es existieren bereits Scores) |
| FinalFlux | **FinalFlux** ✓ | `/finalflux` | – |
| Code Escape Room | **CodeEscape** | `/codeescape` | `codeescape` |
| Service Clash | **ServiceClash** | `/serviceclash` | `serviceclash` |
| Bug Hunter | **BugHunter** | `/bughunter` | `bughunter` |
| Netzwerker – Das Router-Rätselspiel | **NetWorker** | `/networker` | `networker` |
Begründung Keys: Die vorhandenen Routen sind alle klein und zusammengeschrieben (`/mindmatch`, `/syntaxsaga` …) – die neuen Routen folgen dem. Die vier reservierten Keys (`escape-room`, `service-clash`, `bug-hunter`, `netzwerker`) haben **noch keine Scores in der DB** und werden daher jetzt kostenlos auf das einheitliche Schema umbenannt (`codeescape`, `serviceclash`, `bughunter`, `networker`) – zu ändern in `GameScoreController.KNOWN_GAMES`, `ProgressService.GAMES` und den jeweiligen Service-`GAME`-Konstanten. Nur `syntax-saga` behält seinen Key (Bestandsdaten), Anzeigename wird trotzdem `SyntaxSaga`. Anzeigenamen anpassen in: `dashboard.html`-`GAMES`-Array, `ProgressService.GAMES`, Seitentiteln/Templates der neuen Spiele. _Alle nachfolgenden Abschnitte verwenden bereits die neuen Namen; wo im Text noch V9–V12-Design-Details Alt-Keys nennen, gilt die Tabelle hier._
---
## Visuelle Identität: vier Spiele, vier Welten
> ☐ **Noch offen** – wird mit dem Bau der Spiele umgesetzt; auch die Spiel-Akzent-Tokens in `theme.css` sind noch nicht angelegt.
Vorgabe: Die Spiele sollen **grafisch stark ausgeprägt** sein und sich **sehr deutlich** voneinander unterscheiden – nicht nur mechanisch, sondern auf den ersten Blick. Dazu zwei Maßnahmen:
**1. Kontrollierte Styleguide-Erweiterung – Spiel-Akzente:** `theme.css` bekommt pro Spiel ein Akzent-Token-Paar (z. B. `--esc-accent`, `--bh-accent`, `--sc-accent`, `--nw-accent`, je mit Dark-/Light-Wert). Jedes Spiel-CSS überschreibt im Seiten-Root lokal `--accent`/`--glow-*` mit seinem Spiel-Akzent. Die Grundregeln bleiben: Erfolg = blaues ✓, Fehler = rotes ✗, nie grün als Erfolgsfarbe; beide Themes müssen funktionieren.
**2. Eigenes visuelles Vokabular pro Spiel** (Layout, Illustration, Animation, Typografie-Akzente):
| Spiel | Welt-Metapher | Farbstimmung | Prägende visuelle Elemente |
|-------|---------------|--------------|---------------------------|
| **CodeEscape** | Dunkle Facility bei Nacht | Bernstein/Orange auf Tiefschwarz | CRT-Terminals mit Scanline-Overlay, physisches Keypad mit Tasten-Feedback, Raum-„Bühnenbild" als SVG-Illustration (Racks, Türen, flackernde Neonröhre), Fragment-Chips wie Hardware-Token, Alarm-Puls bei Zeitablauf, Tür-Öffnungs-Animation zwischen Räumen |
| **BugHunter** | Noir-Detektivbüro | Entsättigt + Crimson-Akzente | Fallakten-Optik (Aktendeckel, Büroklammer, „VERTRAULICH"-Stempel), IDE-Look mit Zeilennummern-Gutter und Tab-Reitern, roter Ermittlungsfaden zum markierten Bug, „GELÖST"-Stempel-Animation im Debriefing, Schreibmaschinen-Typo für Briefings |
| **ServiceClash** | Hektischer Leitstand/Cockpit | Kühles Cyan + Warnrot | Kanban-artige Ticket-Queue mit einfliegenden Karten, große SLA-Ring-Gauges und Gesundheits-Meter, laufender Event-Ticker unten, Wellen-Countdown-Banner, komisch überzeichnete Ticket-Icons (brennender Drucker 🔥), Screen-Shake bei Major Incidents |
| **NetWorker** | Technische Blaupause | Blueprint-Blau auf Millimeterpapier | Raster-Hintergrund wie Konstruktionszeichnung, Geräte als Schematic-Glyphen, Gummiband-Kabelzug, leuchtende Paket-Punkte, die den echten Trace entlangfliegen und am Fehler-Hop rot zerplatzen, Diagnose-Callouts wie Plan-Anmerkungen |
Dazu je Spiel ein eigenes Karten-Design auf dem Dashboard (Hover-Animation im jeweiligen Spiel-Akzent), damit die Unterscheidbarkeit schon in der Übersicht beginnt. Die Interaktionsformen sind ohnehin grundverschieden: Modal-Rätsel + Keypad (CodeEscape), klickbarer Code-Viewer (BugHunter), Echtzeit-Queue (ServiceClash), frei bespielbare SVG-Canvas (NetWorker).
---
## Das gemeinsame Architektur-Rezept (gilt für alle 4 Spiele)
Jedes neue Spiel folgt exakt dem Muster der vorhandenen Spiele (bester Bauplan: **Dev Lab**, `DevLabService.java` + `devlabgame.js`):
1. **Flyway-Migration** `db/migration/V9__…` ff. (V8 ist die letzte; `ddl-auto=validate` ⇒ Migration ist Pflicht). SQL-Stil: `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `slug VARCHAR UNIQUE`, `sort_order INT DEFAULT 0`, `active BOOLEAN DEFAULT TRUE`, `FK … ON DELETE CASCADE`, Indizes.
2. **Entities** in `model/` (Parent mit `@OneToMany(cascade=ALL, orphanRemoval=true)`, Lösungs-Logik nur serverseitig auf der Entity).
3. **Repository** in `repository/` (`findAllByActiveTrueOrderBySortOrderAscIdAsc()`, `findBySlug`, `countBy…Id`).
4. **Service** in `service/` mit Konstante `GAME = "<key>"`, injiziert `GameScoreService`; `@Transactional(readOnly=true)` für Build/Check, `@Transactional` für `finish`. **Score wird ausschließlich serverseitig berechnet** (`gameScoreService.recordScore(user, GAME, deck, score, moves, seconds)`); Client sendet nur Roh-Antworten.
5. **Punkte-Konventionen der Plattform:** 1. Versuch volle Punkte, 2. Versuch ×0.6, danach ×0.3, aufgedeckte Lösung = 0; Sterne: Quote ≥0.8 → 3, ≥0.5 → 2, sonst 1; `MAX_ATTEMPTS = 3`.
6. **DTOs** in `dto/`: lösungsfreie View-DTOs, Request-DTOs, `*Result` (score, maxScore, solved, total, stars, highscore, newHighscore, plays).
7. **Controller:** `@Controller` (`GET /<spiel>` → Thymeleaf-View) + `@RestController @RequestMapping("/api/<spiel>")` mit `GET /session|board`, `POST /check` (mit `reveal`-Flag), `POST /finish`; `Principal` für den Usernamen.
8. **Seeder** (`CommandLineRunner` in `config/`, Guard `count() > 0`, liest `resources/<spiel>/content.json`).
9. **Admin-CRUD** `@Controller @RequestMapping("/admin/<spiel>")` mit Redirect-after-Post + Karte in `templates/admin/dashboard.html`.
10. **Frontend:** Template in `templates/`, eigenes CSS mit eindeutigem Präfix (nur `theme.css`-Tokens, Erfolg = blaues ✓, Fehler = rotes ✗, **nie grün**), JS als IIFE in `static/js/`, Highscore via `GET /api/games/<key>/stats`, Cache-Busting `?v=N`.
11. **Integration:** Route in `SecurityConfig`-CSRF-Ignore-Liste aufnehmen, Dashboard-Karte in `dashboard.html` von `/comingsoon` auf die echte Route umstellen. (`KNOWN_GAMES` + `ProgressService` enthalten die Keys bereits.)
---
## Teil 1: Spieldesigns
### Spiel 1: Code Escape Room (Key: `escape-room`, Migration V9)
**Konzept:** Der Spieler ist in einer digitalen Umgebung eingesperrt (Serverraum, Admin-Büro, Rechenzentrum). Ein **Szenario** = ein Durchlauf mit 3 Räumen in fester Reihenfolge; jeder Raum enthält 2–3 IT-Rätsel plus ein **Tür-Keypad**. Jedes gelöste Rätsel liefert ein **Code-Fragment** (Ziffern/Hex-Chunk in einer Inventar-Leiste); die Fragmente ergeben zusammen den Türcode zum nächsten Raum. Countdown erzeugt Zeitdruck; ein 2-stufiges Hinweis-System tauscht Punkte gegen Hilfe.
**Lernziele (AP1 → AP2):** Zahlensysteme (Bin/Hex/Dez), Ports & Protokolle, Subnetting, TCP-Handshake/Boot-Reihenfolgen, RAID, Hardware, SQL/Shell-Grundlagen – als Rätsel verpackt statt als Quiz. Schwere Szenarien tragen AP2-Stoff (Verschlüsselung/Zertifikate, Virtualisierung/Container, Backup-Strategien).
**Spielablauf:**
1. **Lobby:** Szenario-Karten (Titel, Thema, Schwierigkeit, Zeitlimit, persönliche Bestzeit).
2. **Raum-Ansicht:** Rätsel-Karten im Terminal-Look, Fragment-Inventar, Tür-Keypad. Rätsel öffnen sich im Modal.
3. **Rätsel-Versuch:** `MAX_ATTEMPTS = 3` wie Dev Lab; danach „Auflösen"-Button. Bei richtig **oder** aufgedeckt liefert der Server das Code-Fragment – Aufdecken blockiert nie den Fortschritt, es nullt nur die Punkte (Plattform-Konvention).
4. **Tür:** Spieler tippt den zusammengesetzten Code; Server validiert (`POST /door`). Falsch → rotes Blinken, keine Punktstrafe (die laufende Uhr ist die Strafe).
5. **Flucht/Ergebnis:** `POST /finish` rechnet alles serverseitig nach; Ergebnis-Screen mit Score-Aufschlüsselung (Rätselpunkte, Türboni, Hinweis-Abzug, Zeitbonus), Sternen, Highscore. „Aufgeben"-Button beendet früher (kein Zeitbonus, `escaped=false`).
**Punkteformel (serverseitig in `/finish` nachgerechnet):**
- Pro Rätsel: `punkte × versuchsfaktor × hinweisfaktor` (Versuch: 1.0/0.6/0.3, aufgedeckt 0; Hinweis: keiner ×1.0, Stufe 1 ×0.8, Stufe 2 ×0.6). Hinweis-Nutzung wird **serverseitig in der HttpSession** protokolliert (der `/hint`-Endpoint merkt sich die höchste gelieferte Stufe pro Rätsel) – der Client kann sie nicht unterschlagen. _(Einzige bewusste Abweichung vom sonst zustandslosen Muster.)_
- Pro geöffneter Tür: pauschal **+50**.
- Sterne aus `baseScore/maxScore` (≥0.8→3, ≥0.5→2). **Zeitbonus danach**, nur bei Flucht: `max(0, round(500 × (zeitlimit − sekunden) / zeitlimit))` – die MindMatch-Idee, auf 15-Minuten-Läufe skaliert. Timer auf 0 bricht nicht ab: HUD wechselt in „ALARM"-Zustand (pulsierendes `var(--danger)`), der Bonus ist nur weg.
- `recordScore(user, "escape-room", szenarioSlug, baseScore + zeitbonus, gelösteRätsel, sekunden)`.
**Datenmodell (`V9__escape_room.sql`):** 3 Tabellen im V8-Stil:
- `escape_scenarios` (id, slug UNIQUE, title, difficulty, description, time_limit_seconds INT DEFAULT 900, sort_order, active)
- `escape_rooms` (id, scenario_id FK CASCADE, title, intro, door_prompt, **door_code VARCHAR(50)**, success_text, sort_order)
- `escape_puzzles` (id, room_id FK CASCADE, puzzle_type VARCHAR(30), prompt, points INT DEFAULT 100, **code_fragment VARCHAR(20)**, sort_order, payload VARCHAR(8000))
- Indizes auf beide FKs. `code_fragment`/`door_code` als Spalten (nicht im Payload), damit Check/Door sie ohne JSON-Parsing haben und der Admin sie auf einen Blick sieht. Hinweise liegen im Payload als `"hints": [...]`.
**Backend:** Entities `EscapeScenario`/`EscapeRoom`/`EscapePuzzle` (Struktur wie `DevLabGame`/`DevLabTask`, LAZY, `addRoom`/`addPuzzle`-Helper). Repositories mit den Standard-Derived-Queries. `EscapeRoomService` mit `KNOWN_TYPES = Set.of("code","choice","order","match")`:
- `buildCatalog()` / `buildSession(slug)` – Szenario mit **allen** Räumen, Rätsel per `sanitize()` lösungsfrei (keine Lösungen, Fragmente, Hints, Türcodes). Raum-Sperre ist Client-Gate, abgesichert durch Server-Türvalidierung.
- `check(puzzleId, reveal, submission)` – Typ `code`: neuer trivialer Checker via `anyEquals(payload.solutions, answer, caseSensitive)`; **`choice`/`order`/`match`: Checker 1:1 aus `DevLabService` kopieren.** Bei `correct || reveal` kommt `"fragment"` in die Antwort.
- `openDoor(roomId, code)`, `hint(session, puzzleId, level)`, `finish(...)` wie oben; plus Admin-Methoden nach Dev-Lab-Vorbild.
**API (`/api/escape-room`):** `GET /scenarios`, `GET /session/{slug}`, `POST /check` (`{puzzleId, reveal, submission:{answer}}` → `{correct, fragment, successText}`), `POST /door` (`{roomId, code}` → `{open, successText}`), `POST /hint` (`{puzzleId, level}` → `{text, level}`), `POST /finish` (`{slug, durationSeconds, escaped, items:[{puzzleId, submission, attempts, revealed}], doors:[{roomId, code}]}` → `{score, baseScore, maxScore, timeBonus, solvedPuzzles, roomsOpened, escaped, stars, highscore, newHighscore, plays}`).
**Frontend:** `templates/escape-room.html`, `static/js/escaperoom.js` (IIFE-State-Machine `LOBBY → INTRO → ROOM(i) → PUZZLE_MODAL → DOOR_TRY → TRANSITION → … → RESULT`), `static/css/escaperoom.css` mit Präfix `esc-`. **Die `choice`/`order`/`match`-Renderer aus `devlabgame.js` (Vertrag `{render, collect, lock, mark}`) portieren**, nur ein `code`-Renderer (Terminal-Artefakt + Textfeld) kommt neu dazu. Layout: HUD oben (Raumtitel, Raum-Punkte ● ● ○, Timer-Chip), Mitte Rätselkarten, unten Fragment-Chips + Keypad-Button. Nur Theme-Tokens, Erfolg blau ✓, Fehler rot ✗.
**Content & Seeder:** `EscapeRoomSeeder` liest `resources/escaperoom/content.json`. Startumfang: **3 Szenarien** (leicht/mittel/schwer) × 3 Räume × 2–3 Rätsel ≈ 22–25 Rätsel. Themen: „Der verlassene Serverraum" (Netzwerk/Zahlensysteme), „Lockdown im Rechenzentrum" (Hardware/RAID/USV), „Das gehackte Büro" (Security/Passwörter/Verschlüsselung). Beispiel-Rätsel (ausgearbeitet im Design): PIN = Dezimalwert von `0b101001` → „41", SSH-Port-Choice, Dienst↔Port-Matching (Türcode „427"), TCP-Handshake-Reihenfolge, nutzbare Hosts im /26-Netz → „62" (Tresorcode „3E62").
**Admin:** `AdminEscapeRoomController` (`/admin/escape-room`), 3 Drilldown-Templates (Szenario-Liste → Szenario mit Räumen → Raum mit Rätseln; Rätsel-Payload als JSON-Textarea wie Dev Lab).
**Aufwand:** MVP ≈ 5,5 PT (Migration/Entities 0,5 · Service 1,5 · API 0,5 · Frontend 2 · Content 0,5 · Admin+Integration 0,5). Ausbau 1 (+1,5 PT): Bestzeiten in der Lobby, „Perfekter Lauf"-Badge. Ausbau 2 (+1–2 PT): Rätseltypen `regex`/`cloze` (Checker existieren wortgleich in `DevLabService`), 2 weitere Szenarien.
---
### Spiel 2: Bug Hunter (Key: `bug-hunter`, Migration V10)
**Konzept:** Der Spieler ist Debugging-Detektiv und arbeitet **Fallakten** ab. Ein Fall („Hunt") ist ein Mini-Projekt aus 2–4 Dateien, das sich falsch verhält; Beweismittel sind simulierte Konsolen-Ausgabe / Stacktrace / Ticket-Text. Pro Bug eine Drei-Phasen-Schleife: **ORTEN** (fehlerhafte Zeile im Tab-Code-Viewer anklicken) → **KLASSIFIZIEREN** (Syntax / Logik / Laufzeit / Sicherheit) → **FIXEN** (korrigierte Zeile aus 4 Kandidaten wählen). Saubere Bugs bauen eine **Combo-Serie** auf. Stufen: Junior / Senior / Principal.
**Abgrenzung zu Dev Lab:** Dev Labs `spot`-Typ ist „finde die eine falsche Zeile". Bug Hunter ist bewusst reicher: Multi-Datei-Navigation, beweisgetriebenes Denken (Stacktrace lesen → Zeile zuordnen), explizite Fehler-Taxonomie (Prüfungsthema!) und Fix-Auswahl – das vollständige Debugging-Denkmodell. Junior-Fälle decken AP1-Stoff ab; Senior/Principal-Fälle gehen auf FIAE-AP2-Niveau (Nebenläufigkeit/Threads, Transaktions-/SQL-Fehler, Design-Pattern-Missbrauch, OWASP-Sicherheitslücken).
**Punkteformel (serverseitig in `/finish`):**
- Phasengewichte pro Bug: Orten 40 %, Klassifizieren 20 %, Fixen 40 %. Phasen-Award = `gewicht × punkte × versuchsfaktor` (1.0/0.6/0.3, aufgedeckt 0).
- Sterne aus `baseScore/maxScore` (Genauigkeitsmaß, ohne Combo).
- **Combo:** Ein Bug ist _sauber_, wenn alle 3 Phasen im 1. Versuch ohne Aufdecken gelingen. Serienposition n bringt `(min(n,4) − 1) × 10 % × punkte` Bonus (2. saubere in Folge +10 %, … 4.+ +30 %); Bonus wird **nach** der Sternberechnung addiert. Kein Timer – Bug Hunter belohnt Präzision, Zeitdruck gehört dem Escape Room.
- Basispunkte pro Bug nach Stufe: Junior 100, Senior 150, Principal 200. `recordScore(user, "bug-hunter", huntSlug, baseScore + comboBonus, gelösteBugs, sekunden)`.
**Datenmodell (`V10__bug_hunter.sql`):**
- `bughunter_hunts` (id, slug UNIQUE, title, code_language, difficulty DEFAULT 'junior', description, briefing VARCHAR(2000), console_output VARCHAR(4000), sort_order, active)
- `bughunter_files` (id, hunt_id FK CASCADE, filename, content VARCHAR(8000), sort_order) – eigene Tabelle, damit der Code-Viewer-Inhalt von allen Bugs geteilt wird und im Admin als Textarea editierbar ist
- `bughunter_bugs` (id, hunt_id FK CASCADE, points DEFAULT 100, sort_order, payload VARCHAR(8000)) – Payload: `{"file","line","category","evidence","fixOptions":[4],"fixIndex","explanation"}`
- Indizes auf beide FKs.
**Backend:** Entities/Repos nach Dev-Lab-Muster. `BugHunterService` mit `CATEGORIES = Set.of("syntax","logik","laufzeit","sicherheit")`, Gewichten `0.4/0.2/0.4`:
- `buildSession(slug)`: Briefing + `consoleOutput` + Dateien als `[{filename, lines:[…]}]` (**serverseitig in Zeilen gesplittet**, damit Zeilenindizes kanonisch sind) + Bugs sanitisiert: nur `{bugId, points, evidence}` – **keine Zeile, keine Kategorie, keine Fix-Optionen**.
- `checkPhase(bugId, phase, reveal, submission)`: `locate` vergleicht file+line, liefert bei Erfolg/Aufdecken `location` zurück; `classify` vergleicht die Kategorie und liefert bei Auflösung erst die **gemischten `fixOptions`** (Phasen-Gate: der Fix-Text würde sonst die Position verraten); `fix` prüft die Wahl (Logik = Dev Labs `checkChoice`) und liefert `fixedLine` + `explanation` fürs Debriefing.
- `finish(...)`: prüft alle Phasen aus den Roh-Antworten neu, Gewichte + Versuchsfaktoren, Streak/Combo, Sterne, `recordScore`.
**API (`/api/bug-hunter`):** `GET /hunts`, `GET /session/{slug}`, `POST /check` (`{bugId, phase, reveal, submission}`), `POST /finish` (`{slug, durationSeconds, items:[{bugId, locate:{file,line,attempts,revealed}, classify:{category,…}, fix:{choice,…}}]}` → `{score, baseScore, maxScore, comboBonus, maxCombo, bugsSolved, totalBugs, stars, highscore, newHighscore, plays}`).
**Frontend:** `templates/bug-hunter.html`, `static/js/bughunter.js` (State-Machine `LOBBY → BRIEFING → HUNT {pro Bug: LOCATE → CLASSIFY → FIX → DEBRIEF} → RESULT`), `static/css/bughunter.css` Präfix `bh-`. Layout: **links 60 %** Datei-Tabs + Monospace-Code-Viewer mit Zeilennummern-Gutter (Zeilenklick nur in LOCATE aktiv); **rechts 40 %** Beweis-Karte (Terminal-Style `consoleOutput`, Briefing, Bug-Hinweis), Aktions-Panel (wechselt pro Phase; Kategorie-/Fix-Buttons nutzen das `choice`-Renderer-Muster aus `devlabgame.js`), Combo-Chip + Fortschritt („Bug 2/3"). Debriefing tauscht die Bug-Zeile sichtbar gegen `fixedLine` (blaues Diff-Highlight).
**Content & Seeder:** `BugHunterSeeder` liest `resources/bughunter/content.json`; der komplette Bug-Knoten wird wie bei `DevLabSeeder` per `toString()` zum Payload. Startumfang: **6 Fälle** – 3 Junior (1 Datei, 1–2 Bugs: Off-by-one, `=` vs. `==`, fehlendes `break`), 2 Senior (2 Dateien: NPE via Stacktrace, SQL-Injection, Integer-Division), 1 Principal (3 Dateien, Symptom in Datei A, Ursache in Datei B) ≈ 13–15 Bugs. Drei Beispiel-Fälle sind im Design voll ausgearbeitet („Fall #1: Die falsche Rechnung" – Off-by-one in Java; „Fall #2: Das offene Login" – SQL-Injection in PHP; „Fall #3: Der Absturz um Mitternacht" – NPE mit Stacktrace-Lesen), inkl. Code, Fix-Optionen und Erklärungen.
**Admin:** `AdminBugHunterController` (`/admin/bug-hunter`), 2 Templates (Fall-Liste + Fall-Detail mit Datei-Textareas und Bug-Payload-JSON). Service validiert beim Speichern, dass `file` im Fall existiert und `line` im Zeilenbereich liegt.
**Aufwand:** MVP ≈ 4,5 PT. Ausbau 1 (+1 PT): Freitext-Fix statt Optionen (`anyEquals` mit Whitespace-Normalisierung wie Dev-Lab-`cloze`), „Sauber gelöst"-Badge. Ausbau 2 (+1 PT): „Blitzjagd"-Tagesmodus (3 zufällige Junior-Bugs, 90-s-Soft-Timer).
---
### Spiel 3: Service Clash (Key: `service-clash`, Migration V11)
**Konzept: „Deine Schicht am Service Desk."** Der Spieler ist Junior-Admin im First-Level-Support. Tickets treffen auf einer Zeitleiste ein; jedes muss **eingeordnet** (Incident vs. Service Request), **priorisiert** (Hoch/Mittel/Niedrig anhand von Impact-×-Dringlichkeit-Hinweisen im Tickettext) und **gelöst** werden (richtige Erstmaßnahme aus 3–4 Optionen) – bevor sein SLA-Timer abläuft. Unbehandelte Tickets beschädigen die „Systemgesundheit"; bei 0 endet die Schicht vorzeitig.
**Lernziele (AP1 → AP2):** Incident-/Service-Request-Klassifikation (ITIL-light, explizites IHK-Thema), Prioritätsbewertung, korrekte Erstmaßnahmen (Troubleshooting-Reihenfolge, Eskalationsdisziplin, Phishing erkennen statt „lösen"). Höhere Schichten bringen FISI-AP2-Stoff: Change Management, Monitoring-/Eskalationsketten, Security-Incident-Behandlung, Backup-/Restore-Entscheidungen.
**Spielablauf:** Eine **Schicht** = ein Level mit 3 Wellen (Vormittag/Mittag/Nachmittag), je 60–90 Echtzeit-Sekunden mit steigender Spawn-Rate; Gesamtspielzeit 3–5 Minuten, zwischen Wellen 5 s Verschnaufpause. Pro Ticket im Detail-Panel:
1. **Einordnung** [Incident]/[Service Request] – Single-Shot, falsch = 0 Punkte für diesen Anteil, richtige Antwort wird sofort gezeigt (Lerneffekt). 20 % der Ticket-Basispunkte.
2. **Priorität** [Hoch]/[Mittel]/[Niedrig] – Single-Shot. 30 %.
3. **Lösung** – 3–4 Aktionen (servergemischt), Plattform-Versuchsleiter 1.0/0.6/0.3; nach dem 3. Fehlgriff Auflösung + Erklärung, Ticket zählt als „eskaliert" (0 für diesen Anteil). 50 %.
**SLA & Gesundheit:** `slaSeconds` je Priorität. In SLA gelöst → ×1.0, verspätet → ×0.5, nie gelöst → 0 + Gesundheitsschaden. Start 100; SLA-Bruch −10 (Hoch: −20), ungelöst am Schichtende −15. Endbonus: `gesundheit × 2` (max. 200).
**Punkteformel (nur serverseitig):**
```
ticketPunkte = B × (0.2·einordnungOk + 0.3·prioOk + 0.5·lösungsLeiter) × slaFaktor
score = Σ ticketPunkte + gesundheit×2 (B: Routine 60, Standard 100, Major Incident 150)
maxScore = Σ B + 200 ; Sterne nach Plattform-Konvention
```
**Der Server-autoritäts-Trick für Echtzeit (Kernidee):** Runden bleiben zustandslos –
- `GET /session/{slug}`: Server zieht einen **Seed**, generiert daraus deterministisch den Schichtplan (Ticket-Auswahl aus dem Pool, Spawn-Zeitpunkte, Options-Mischung; Payloads sanitisiert ohne Lösungsfelder) und liefert Plan + Seed.
- Jeder Klick geht an `POST /check` (sofortiges ✓/✗-Feedback; Lösungen verlassen den Server nie).
- `POST /finish` sendet `{slug, seed, decisions[], durationSeconds}`; der Server **regeneriert die Schicht aus dem Seed und re-simuliert das Entscheidungslog** (Plausibilitätschecks: Zeitstempel ≥ Spawn-Offset, ≤ Schichtdauer, monoton pro Ticket) und berechnet Gesundheit, SLA-Faktoren und Score neu. Härtungs-Ausbau: HMAC-signierter Seed.
**Datenmodell (`V11__service_clash.sql`):**
- `service_clash_shifts` (id, slug UNIQUE, title, description, difficulty INT, wave_count INT DEFAULT 3, wave_seconds INT DEFAULT 60, spawn_count INT DEFAULT 12, settings VARCHAR(4000) /* JSON: SLA-Defaults, Health-Regeln, Prio-Stufe an/aus */, sort_order, active)
- `service_clash_tickets` (id, shift_id FK CASCADE, wave INT, base_points INT DEFAULT 100, sort_order, payload VARCHAR(8000)) + FK-Index
- Ticket-Payload: `{title, requester, text, kind (normal|phishing), options[], correctIndex✂, category✂, priority✂, slaSeconds, explanation✂, successText}` (✂ = wird von `sanitize()` entfernt).
**Backend:** Entities `ServiceClashShift`/`ServiceClashTicket`, Standard-Repos (`findAllByShiftIdOrderByWaveAscSortOrderAscIdAsc`, `countByShiftId`). `ServiceClashService`: `buildCatalog()`, `buildSession(slug)` (Seed + `generatePlan()`), `check(templateId, step, value, reveal)` mit `step ∈ classify|priority|solve` (Vergleich immer über Options-**Text**, nie Indizes – immun gegen Mischungs-Versatz), `finish(...)` (Plan aus Seed regenerieren, Log re-simulieren, `recordScore(user, "service-clash", slug, score, gelöste, sekunden)`), Admin-Methoden nach Dev-Lab-Vorbild.
**API (`/api/service-clash`):** `GET /catalog`, `GET /session/{slug}` (liefert `seed`, Wellen-Konfiguration, Ticket-Instanzen `{instanceId, templateId, wave, spawnAtMs, …}`), `POST /check`, `POST /finish` → `{score, maxScore, stars, resolved, total, slaBreaches, health, highscore, newHighscore, plays, tickets[Debrief-Aufschlüsselung]}`.
**Frontend:** `templates/service-clash.html`, `static/js/serviceclash.js` (State-Machine `SHIFT_SELECT → BRIEFING → RUNNING ⇄ WAVE_BREAK → DEBRIEF`; **ein** `setInterval(250 ms)`-Tick: fällige Tickets spawnen, SLA-Ringe aktualisieren), `static/css/service-clash.css` Präfix `sc-`. Layout: Topbar (Schicht-Uhr, Wellen-Pips, Gesundheits-Meter, „Gelöst 4/8"), links die Ticket-Queue (Karten mit SLA-Ring, pulsierender roter Rand bei Überfälligkeit), Mitte das aktive Ticket mit 3-Stufen-Flow, unten ein Event-Log („14:02 Ticket ‚ERP down' eingegangen"). SLA-Ringe: `var(--accent)`, unter 25 % `var(--danger)`.
**Schwierigkeitskurve:** Schicht 1 „Erster Arbeitstag" (8 Tickets, großzügige SLAs, Prio-Stufe deaktiviert), Schicht 2 „Normalbetrieb" (12 Tickets, Prio aktiv, erster Phishing-Köder), Schicht 3 „Patch Day Chaos" (16 Tickets, knappe SLAs, Major Incident mit Folgetickets in Welle 3). **Replay-Wert:** Ticket-Pool größer als `spawnCount` → jede Runde ein anderer Mix (Seed).
**Content & Seeder:** `ServiceClashSeeder` liest `resources/service-clash/content.json`. Startumfang: **3 Schichten × 18–20 Ticket-Vorlagen ≈ 55 Tickets.** Vier Beispiel-Tickets sind voll ausgearbeitet (Drucker-Queue/Incident-mittel, Azubi-Onboarding/Service-Request-niedrig, ERP-Totalausfall/Major-Incident-hoch mit Sofort-Eskalation, Phishing-Mail mit verdächtiger Absenderdomain).
**Admin:** `AdminServiceClashController` (`/admin/service-clash`), Schicht-Liste + Detailseite (Schicht-Formular inkl. Settings-JSON, Ticket-Tabelle, Payload-JSON-Textarea mit Schema-Hinweis; Speichern validiert `correctIndex` gegen `options`).
**Aufwand:** MVP ≈ 7,5 PT (Service inkl. Seed-Plan + Re-Simulation 2 · Frontend 3 · Content 1 · Admin 1 · Rest 0,5+0,5). Ausbau (+3 PT): Major-Incident-Folgeketten, Combo-Serien, HMAC-Seed, Endlosmodus „Überstunden".
---
### Spiel 4: Netzwerker – Das Router-Rätselspiel (Key: `netzwerker`, Migration V12)
**Konzept:** Netzwerk-Puzzle in der Tradition von Schaltkreis-Spielen. Jedes Level zeigt eine **SVG-Topologie** (PCs, Switch, Router, Server, Cloud). Geräte haben fehlende oder kaputte Konfiguration (IP, Maske, Gateway, Kabel, statische Routen). Ziel: alle **Ping-Ziele** erfüllen („PC1 muss SRV1 erreichen", später auch negativ: „Gast-PC darf den Server NICHT erreichen"). „Pings testen" ruft einen **serverseitigen Netzwerk-Simulator**, der jeden Ping Hop-für-Hop auflöst und Pass/Fail plus didaktische Diagnose liefert („Das Paket verlässt PC1 nicht: Laut Maske liegt das Ziel im eigenen Netz – stimmt die Maske?").
**Lernziele (AP1-Kernstoff → AP2, passt zu vorhandenem FinalFlux-Content IP-Berechnung/VLAN):** IPv4-Adressierung, Subnetzmasken/CIDR, Netz-/Broadcast-Adressen, Gateway-Logik, doppelte IPs, statische Routen; auf AP2-Niveau (FISI): VLAN-Segmentierung mit Negativ-Zielen, Routing über mehrere Hops, Firewall-Regeln, im Ausbau IPv6.
**Spielablauf:** Session = eine **Welt** aus 4–6 Leveln in Folge (Dev-Lab-Session-Form). Fünf Level-Typen bei einheitlichem Datenmodell: `assign` (leere IP/Maske/Gateway-Felder ausfüllen), `cable` (fehlende Verbindungen ziehen), `fix` (1–3 eingebaute Fehler finden: falsche Maske, Gateway außerhalb des Subnetzes, doppelte IP, Host auf Netzadresse), `route` (statische Routen-Zeilen: Zielnetz-CIDR + Next Hop), `vlan` (Ausbau, mit Negativ-Zielen).
**Versuche & Punkte:** Jeder „Pings testen"-Klick mit mindestens einem roten Ziel verbraucht einen Versuch (1.0/0.6/0.3, `MAX_ATTEMPTS=3`, danach „Lösung anzeigen" = 0 Punkte). **Bewertet wird ausschließlich per Simulation, nie per Exact-Match** – viele Konfigurationen sind gültig (jede freie Host-IP funktioniert); die Musterlösung dient nur dem Aufdecken. Levelpunkte 100–200; Sterne am Weltende nach Konvention; `/finish` re-simuliert die final eingereichten Konfigurationen serverseitig. `recordScore(user, "netzwerker", weltSlug, score, gelöste, sekunden)`.
**Datenmodell (`V12__netzwerker.sql`):**
- `netzwerker_worlds` (id, slug UNIQUE, title, description, difficulty INT, sort_order, active)
- `netzwerker_levels` (id, world_id FK CASCADE, title, level_type VARCHAR(30) /* assign|cable|fix|route|vlan */, points INT DEFAULT 100, sort_order, payload VARCHAR(8000) /* JSON-Topologie inkl. Musterlösung */) + FK-Index
**Der Simulator – `service/NetSimulator.java` (Herzstück, pures Java ohne Spring, voll unit-testbar):**
- Records: `Net(nodes, links, routes, vlans)`, `NodeCfg`, `IfaceCfg(ipLong, prefix, gatewayLong)`, `Route(destNet, prefix, nextHop)`.
- `ping(net, from, to)`: (1) Validierung: unparsebare IP/Maske, Host-Teil 0/alles-1 → `network_address`/`broadcast_address`, doppelte IP im L2-Segment → `duplicate_ip`. (2) Gleiches Subnetz aus Quellsicht (`(ip & maske) == (ziel & maske)`) → L2-BFS über Links durch Switches (nie durch Router; VLAN-gefiltert). (3) Anderes Subnetz → Gateway nötig: gesetzt? im Quell-Subnetz? L2-erreichbar? Dann leitet der Router weiter: Ziel in direkt verbundenem Subnetz → zustellen; sonst statische Routen (Longest-Prefix-Match) → Next Hop → Rekursion mit TTL 8 (`routing_loop`-Schutz). Kein Treffer → `no_route`.
- Fehlergründe werden im Service auf didaktische deutsche Meldungen gemappt.
**`NetzwerkerService`:** `buildCatalog()`, `buildSession(slug)` (Level per `sanitizeLevel()`: Werte editierbarer Felder, zu ziehende Links, Routen-Lösungszeilen und `solution`-Block entfernt; gesperrte Werte, Positionen, Ziele, Hints gehen mit), `check(levelId, reveal, config)` (`mergeSubmission()` legt eingereichte Werte **nur über editierbare Felder** – Manipulation gesperrter Felder wird ignoriert; alle Ziele durch `NetSimulator.ping`; Antwort mit Trace/Diagnose pro Ziel), `finish(...)`, Admin-CRUD **plus Qualitäts-Gate beim Speichern: die hinterlegte Musterlösung wird durch den Simulator geschickt – Speichern wird abgelehnt, wenn sie nicht alle Ziele erfüllt.**
**API (`/api/netzwerker`):** `GET /catalog`, `GET /session/{slug}`, `POST /check` (`{levelId, reveal, config:{ifaces[], links[], routes[], vlans[]}}` → `{allPassed, validation[], goals:[{id, passed, trace[] | failAt, reason, message}]}`; Masken-Eingabe akzeptiert `24`, `/24` oder `255.255.255.0` – Server normalisiert), `POST /finish` → Dev-Lab-Result-Form.
**Frontend:** `templates/netzwerker.html`, `static/js/netzwerker.js` (State-Machine `WORLD_SELECT → LEVEL_INTRO → EDITING ⇄ CHECKING → LEVEL_DONE → SUMMARY`), `static/css/netzwerker.css` Präfix `nw-`. **SVG-Ansatz:** ein `<svg viewBox="0 0 1000 620">`, Geräte-Glyphen als `<symbol>`-Defs im Strichstil der Dashboard-Icons, Positionen aus dem Payload; Links als `<path>`, Ports als Kreise an der Gerätekante. **Die Paket-Animation ist die Belohnung:** bei Erfolg wandert ein leuchtender Punkt entlang des tatsächlich vom Server gelieferten `trace`-Pfads (`requestAnimationFrame`); bei Fehlschlag stirbt er exakt am `failAt`-Hop mit rotem Burst. Kabelmodus: Port A klicken → Gummiband-Linie → Port B; gezogenes Kabel per Klick löschen. Layout: Canvas links/Mitte, rechts Ziel-Liste (mit Diagnosetexten unter roten Zielen) + Konfigurations-Panel des gewählten Geräts, Footer mit „Pings testen" / „Lösung anzeigen" / Hinweis-Toggle.
**Schwierigkeitskurve (Welten):** 1. „Ein Netz für alle" (Same-Subnet-Basics), 2. „Über den Router" (Gateways, zwei Subnetze, erstes Negativ-Ziel), 3. „Routen & Fehlersuche" (zwei Router, statische Routen, Duplikat-IP-Jagd), 4. (Ausbau) „VLAN-Werkstatt".
**Content & Seeder:** `NetzwerkerSeeder` liest `resources/netzwerker/content.json`. Startumfang: **3 Welten × 5 Level = 15 Level.** Drei Beispiel-Level voll ausgearbeitet: „Erste Verbindung" (assign: PC1 passend zu 192.168.10.20/24 konfigurieren), „Die falsche Maske" (fix: /25-vs-/24-Konflikt, .200 liegt außerhalb des ersten /25-Teilnetzes), „Tor zum Serverraum" (assign: IP+Maske+Gateway über Router zwischen 192.168.1.0/24 und 192.168.2.0/24).
**Admin:** `AdminNetzwerkerController` (`/admin/netzwerker`), Welt-Liste + Detailseite mit Level-Payload-JSON-Textarea (eingeklapptes Schema-Cheatsheet) und dem Musterlösungs-Validierungs-Gate.
**Aufwand:** MVP ≈ 9,5 PT (**NetSimulator + Unit-Tests zuerst: 2,5** · Migration/Entities/Service/API 1,5 · SVG-Frontend 3,5 · Content 1 · Admin 1). Ausbau (+4,5 PT): VLAN-Welt, DHCP-Modus, Sandbox, visueller Level-Editor.
---
### Empfohlene Baureihenfolge & Gesamtaufwand
0. **Vorarbeiten:** Namensschema umsetzen (Dashboard-Namen, Keys, `devlab` in `ProgressService` = W1) · K2 (Legacy-Endpunkt löschen) und W8 (N+1-Muster) beheben, damit die neuen Services das saubere Muster übernehmen · Spiel-Akzent-Tokens in `theme.css` anlegen. ≈ 1 PT — **✅ erledigt bis auf die Spiel-Akzent-Tokens.**
1. **CodeEscape (V9)** – validiert die Wiederverwendung der DevLab-Checker/-Renderer mit minimalem Risiko. ≈ 5,5 PT
2. **BugHunter (V10)** – übernimmt Terminal-Styling und Choice-Renderer aus Schritt 1. ≈ 4,5 PT
3. **NetWorker (V12)** – **Simulator zuerst, testgetrieben** (pures Java, kein UI-Risiko), dann SVG-Frontend. ≈ 9,5 PT
4. **ServiceClash (V11)** – komplexestes Frontend (Echtzeit-Tick); profitiert von allen Vorarbeiten. ≈ 7,5 PT
**Gesamt-MVP aller vier Spiele: ≈ 28 Personentage.** Der Navigations-Umbau (Abschnitt 2.3) und die restlichen Teil-2-Punkte können parallel oder dazwischen erfolgen; empfohlen ist der Sidebar-/Tab-Umbau **vor** dem vierten Spiel, damit alle neuen Seiten die Fragmente nutzen.
---
## Teil 2: Verbesserungen & Optimierungen am Bestand
> ✅ **Umgesetzt am 2026-07-12** (K1–K3, W1–W8, N1–N6, S1–S8, S10 – Details im CHANGELOG). Offen bleiben: S9/i18n (bewusst zurückgestellt), S3 (Regel für die neuen Spiele) sowie die schrittweise Fragment-Migration der restlichen Spiel-/Admin-Templates. **K1 vollständig abgeschlossen:** Gitea-Secret angelegt, Produktionspasswort rotiert und Runner-Abgleich erfolgreich deployt (Workflow 43, Commit `4f5042d`). Das Verfahren und der manuelle Notfallweg stehen in [`pw_anleitung.md`](pw_anleitung.md).
### 2.1 Kritisch (vor/mit dem nächsten Release beheben)
| # | Problem | Ort | Maßnahme |
|---|---------|-----|----------|
| K1 ✅ | **DB-Passwort im Repo** (`SPRING_DATASOURCE_PASSWORD: mindflux_pass`) | `docker-compose.yml:11` | In Gitea-Secret auslagern (wie die Admin-Passwörter bereits); Passwort rotieren. |
| K2 ✅ | **Unsicherer Legacy-Endpunkt `/users/save`**: speichert User ohne BCrypt-Hashing, ohne Validierung, mit frei wählbarer Rolle (Mass Assignment) – für jeden eingeloggten Nutzer erreichbar | `UserViewController.java:25–29` | `UserViewController` + `user-form.html` + `user-success.html` ersatzlos löschen (toter Legacy-Code). |
| K3 ✅ | **Keine Tests** – `src/test` existiert nicht, obwohl `spring-boot-starter-test` deklariert ist | `pom.xml:88–93` | Mindestens: Unit-Tests für Score-Berechnungen (`GameScoreService`, `SyntaxSagaService`, `DevLabService.check`-Dispatcher) und einen `@WebMvcTest` für die Security-Regeln (`/admin/**` nur ADMIN). Die Netzwerk-Simulation des neuen Netzwerker-Spiels unbedingt testgetrieben bauen. |
### 2.2 Wichtig (zeitnah)
| # | Problem | Ort | Maßnahme |
|---|---------|-----|----------|
| W1 ✅ | **Dev Lab fehlt auf der Statistik-Seite** – Scores werden geschrieben, aber nie angezeigt | `ProgressService.GAMES` (Zeile 18–27) | `GAMES.put("devlab", "Dev Lab");` ergänzen (Reihenfolge wie Dashboard). |
| W2 ✅ | **XP/Level/Streak sind Dummy-Daten** (`getOverallProgress` hardcoded 432/2400/Level 5) | `ProgressService.java:35–44` | Echte Aggregation aus `game_scores`: XP = Summe aller Scores (oder gedeckelt pro Spiel), Level per Schwellen-Tabelle, richtigen Antworten aus `moves` ableiten. Gleiches Fundament später für Achievements nutzen. |
| W3 ✅ | **Achievements sind Dummy-Daten**, nicht mit `game_scores` verdrahtet | `AchievementServiceImpl` | Nach `recordScore()` einen Achievement-Check auslösen (z. B. „Erster Sieg", „3 Sterne in Syntax Saga", „5 Spiele an einem Tag"). Ereignisgesteuert im `GameScoreService` (ein Hook-Punkt statt in jedem Spiel). |
| W4 ✅ | **Kein globales Error-Handling** – Admin-Controller werfen `IllegalArgumentException` → Whitelabel-500; APIs liefern leere DTOs statt 404 | alle Controller | Ein `@ControllerAdvice` (HTML: eigene `error.html`) + `@RestControllerAdvice` (JSON: `{error, message}` mit korrekten Statuscodes). `ResponseStatusException` in Services nutzen. |
| W5 ✅ | **Keine Bean Validation** – kein `spring-boot-starter-validation`, keine `@Valid`/`@NotBlank`/`@Size`; keine Passwort-Mindestlänge, kein E-Mail-Formatcheck | `pom.xml`, `RegisterController.java:34–42`, `User.java` | Starter ergänzen; DTO-Validierung für Registrierung (Passwort ≥ 8 Zeichen, E-Mail-Format) und die neuen Spiel-APIs. |
| W6 ✅ | **Registrierungs-Race + tote Templates**: App prüft Username case-insensitiv, DB-UNIQUE ist case-sensitiv → Race mit unbehandeltem 500; `register.html` (ohne `confirmPassword`-Feld) und `login.html` sind tot/kaputt | `RegisterController.java:40`, `V1__initial_schema.sql` | Funktionalen Index `UNIQUE (lower(username))` per Migration; `DataIntegrityViolationException` abfangen → Fehlermeldung; tote Templates `register.html`/`login.html` löschen. |
| W7 ✅ | **`System.out.println` / `printStackTrace` statt Logging** (~20 Stellen, u. a. `ProfileController.java:144`) | Seeder, `AdminSetup`, `ProfileController`, `AppStartCheck` | Auf SLF4J (`LoggerFactory.getLogger`) umstellen; `e.printStackTrace()` durch `log.error(..., e)` ersetzen. |
| W8 ✅ | **N+1-Queries**: `TechtacularService.buildBoard` lädt Fragen+Antworten lazy pro Kategorie; `categoryRows` macht ein `count` pro Kategorie | `TechtacularService.java:46–73, 133–137` | `@EntityGraph`/`JOIN FETCH` für Board-Aufbau; Zeilen-Counts als eine `GROUP BY`-Query. Bei Seed-Datenmengen unkritisch, aber das Muster wird sonst in die 4 neuen Spiele kopiert – **daher vor deren Bau beheben bzw. in den neuen Services von Anfang an richtig machen.** |
### 2.3 Navigation & Profil-Bereich (vom Nutzer gemeldet – bestätigt)
Der Verdacht „Profilbutton fehlt / Einstellungen nicht im Profil / irgendwas ist schief" trifft zu. Befund:
| # | Befund | Ort |
|---|--------|-----|
| N1 ✅ | **Kein „Profil"-Eintrag in der Dashboard-Sidebar-Navigation.** Das Profil ist nur über den kleinen User-Block unten in der Sidebar erreichbar (`<a class="mf-profile" href="/profile">`), der nicht als Button erkennbar ist – und dessen Chevron-Pfeil (▼) ein Dropdown suggeriert, das nicht existiert. | `dashboard.html:72–82` |
| N2 ✅ | **Zwei konkurrierende Navigationssysteme:** Die Sidebar führt zu Knowledge Core / Statistiken / Achievements / Einstellungen; die Profilseite hat eine **eigene Tab-Leiste** (Profil / Fortschritt / Erfolge / Soziales / Einstellungen). Benennungen inkonsistent („Statistiken" vs. „Fortschritt", „Achievements" vs. „Erfolge"), Mengen inkonsistent (Knowledge Core fehlt in den Tabs, Soziales fehlt in der Sidebar). | `dashboard.html:45–70` vs. `profile.html:35–41` |
| N3 ✅ | **Die Tab-Leiste existiert nur auf `profile.html`.** Klickt man einen „Tab" (z. B. Einstellungen), landet man auf einer Seite **ohne** Tabs – die Tab-Metapher bricht sofort. `settings.html` hat nur „⟵ Zurück zum Profil", auch wenn man von der Dashboard-Sidebar kam (asymmetrische Navigation). | Grep `mf-tab-nav`: nur `profile.html` (+ eine Admin-Seite) |
| N4 ✅ | **`/profile/settings` gehört nicht zum Profil-Code:** Die URL suggeriert Profil-Zugehörigkeit, wird aber von einem separaten `SettingsController` bedient; die Passwort-Änderung dagegen liegt im `ProfileController` (`/profile/change-password`) und redirectet nach `settings`. Zuständigkeiten sind verschränkt. | `SettingsController.java`, `ProfileController.java:154–177` |
| N5 ✅ | **`settings.html` ist zur Hälfte Attrappe:** Theme-Select (Hinweistext verweist auf den echten Umschalter), Sprach-Select und Benachrichtigungs-Checkboxen haben **keine Funktion/kein Formular**. Real sind nur Passwort ändern und Profil löschen. Zudem hartkodierte Farbe `#c0392b` statt `var(--danger)` (Token-Regel-Verstoß). | `settings.html:40–70, 37` |
| N6 ✅ | **Sidebar-Widgets sind Dummy-Daten:** LEVEL 12 / 3250/5000 XP / „STREAK 7 Tage" sind hart ins Template geschrieben – deckt sich mit W2 (XP/Level-Dummies im `ProgressService`). | `dashboard.html:84–91` |
**Lösungsansatz (ein zusammenhängender Umbau, verzahnt mit S1/Layout-Fragmenten):**
1. **Ein Navigationsmodell:** Die Sidebar wird als Thymeleaf-Fragment extrahiert und um einen expliziten Eintrag **„Profil" → `/profile`** ergänzt; der User-Block bleibt als Shortcut, verliert aber den irreführenden Chevron (oder bekommt ein echtes Dropdown mit Profil/Einstellungen/Logout).
2. **Profil-Bereich konsolidieren:** Die Tab-Leiste wird ebenfalls Fragment und erscheint **auf allen** Profil-Unterseiten (Profil, Statistiken, Erfolge, Soziales, Einstellungen) mit einheitlichen Namen (Empfehlung: „Statistiken" und „Erfolge" überall). Knowledge Core entweder in die Tabs aufnehmen oder bewusst nur in der Sidebar führen – einmal entscheiden.
3. **Controller aufräumen:** `SettingsController` in den `ProfileController` mergen (oder Settings vollständig auslagern inkl. Passwort-Endpunkt) – eine Zuständigkeit pro Bereich.
4. **Attrappen entfernen oder verdrahten:** Theme-Select an `theme.js` anbinden (Auswahl persistiert in `localStorage`, ersetzt den Hinweistext); Sprach-Select und Benachrichtigungen **ausbauen**, bis es die Funktionen gibt. `#c0392b` → `var(--danger)`.
5. N6 wird durch W2 (echte XP/Level aus `game_scores`) gelöst; bis dahin Widgets ausblenden statt Fantasiewerte zeigen.
### 2.4 Sinnvoll (mittelfristig)
| # | Thema | Maßnahme |
|---|-------|----------|
| S1 ✅ teilweise | **Template-Duplikation** – 33 Templates ohne ein einziges `th:fragment`; jede Seite dupliziert `<head>`, Sidebar, Footer | `templates/fragments/layout.html` mit `head`, `sidebar`, `footer`-Fragmenten einführen; schrittweise umstellen (zuerst bei den 4 neuen Spielen verwenden, dann Bestand migrieren). |
| S2 ✅ | **4 parallele User-Anlage-Pfade** mit inkonsistenten Regeln (`RegisterController`, REST-`UserController`, `AdminUserController`, `UserViewController`) | Nach Löschung von K2: Restlogik in einen `UserService` konsolidieren (ein Ort für Encoding, Rollen, Uniqueness). |
| S3 ☐ | **Score-Manipulation durch eigene Nutzer möglich** (Roh-Werte fälschbar, Reveal-vor-Finish) | Akzeptables Risiko (nur eigener Highscore), aber bei den neuen Spielen von Anfang an minimieren: Server generiert Runden-Daten mit IDs, `finish` validiert nur bekannte IDs, Zeit-Plausibilitätscheck (z. B. `durationSeconds ≥ 1`, Obergrenzen für `pairs`/`moves`). |
| S4 ✅ | **Infrastruktur**: kein Actuator/Healthcheck, Dockerfile läuft als root, kein Dependency-Layer-Caching, CI ohne Tests, Java-Versionskonflikt (pom: 17, Dockerfile: 21) | `spring-boot-starter-actuator` (+ `HEALTHCHECK` in compose), `USER`-Directive, Dockerfile zweistufig mit `dependency:go-offline`-Layer, Test-Stage in `.gitea/workflows/mindflux.yml`, Java-Version angleichen (empfohlen: beide auf 21). |
| S5 ✅ | **Datenmodell**: `game_scores`/`achievements` referenzieren `username` ohne FK; `email` ohne UNIQUE | FK auf `users(username)` + `UNIQUE(email)` per Migration ergänzen (vorher Dubletten bereinigen). |
| S6 ✅ | **Logout per GET** (`SecurityConfig.java:36`) – kleiner Logout-CSRF-Vektor | Auf POST-Logout mit CSRF-Token umstellen (kleines Formular im Sidebar-Footer). |
| S7 ✅ | **Kein Brute-Force-Schutz** auf `/login` | Einfacher In-Memory-Rate-Limiter (z. B. Bucket pro IP/Username) oder Verzögerung nach Fehlversuchen. |
| S8 ✅ | **Toter Code**: `AppStartCheck`, `ProfileController.getFileExtension`, Legacy-CSS (`auth.css`, …) | Aufräumen (niedrige Priorität, aber billig). |
| S9 ☐ | **i18n**: alles hart deutsch | Nur angehen, falls Mehrsprachigkeit je geplant ist – sonst bewusst so lassen (Zielgruppe ist deutsch). |
| S10 ✅ | **FinalFlux auf die gesamte Ausbildung erweitern (AP2)** – aktuell rein AP1-fokussiert | Eigener AP2-Ast mit Sidebar-Blöcken „AP2 Anwendungsentwicklung" und „AP2 Systemintegration"; gleiche Struktur wie bisher (statische Themenseiten unter `static/finalflux/` + FluxCheck-Frage-JSONs). Startthemen FIAE: Entwurfsmuster, Testverfahren, SQL-Vertiefung, Softwarequalität; FISI: Netzwerksicherheit, Monitoring, Hochverfügbarkeit, Automatisierung. |
---
## Teil 3: FluxNet – Community & Soziales
> Ersetzt den `/soziales`-Platzhalter („Straßen? Wo wir hingehen, brauchen wir keine Straßen!"). Ziel: User können sich befreunden, intern miteinander schreiben, gemeinsam lernen und Achievements vergleichen – **vollständig selbst gehostet, ohne einen einzigen externen Dienst**, und DSGVO-konform von Grund auf.
### 3.1 Funktionsumfang
| Funktion | Beschreibung |
|----------|--------------|
| **Freundschaften** | Anfrage per Username-Suche senden → Gegenseite bestätigt/lehnt ab. Nur bestätigte Freunde sehen einander im FluxNet. |
| **Interner Messenger** | 1:1-Direktnachrichten zwischen bestätigten Freunden. Ungelesen-Badge am Glocken-Icon der Topbar. Kein Gruppenchat in Phase 1. |
| **Blockieren** | Blockiert = keine Anfragen, keine Nachrichten, keine Profileinsicht – in beide Richtungen serverseitig geprüft. Bestehende Freundschaft wird getrennt. Der Blockierte erhält **keine** Benachrichtigung (still). Entblocken jederzeit über die Einstellungen. |
| **Achievements vergleichen** | Freundesprofil zeigt den Achievement-Katalog nebeneinander (meine/seine freigeschalteten) und die Spiel-Highscores im Vergleich – nur, was die Privacy-Einstellungen des anderen erlauben. |
| **Gemeinsam lernen** | Phase 2: **Lern-Duell** – einen Freund zu Spiel + Deck herausfordern, beide spielen binnen Frist, der Server vergleicht die echten `game_scores`-Ergebnisse. Phase 3: gemeinsame Lernräume (identisches Board für beide über die Seed-Mechanik aus dem ServiceClash-Design). |
### 3.2 Datenmodell (nächste freie Flyway-Migrationen)
Alle Tabellen mit FK auf `users(username)` und `ON DELETE CASCADE ON UPDATE CASCADE` (Muster aus V10) – damit greift das Löschkonzept automatisch.
```sql
CREATE TABLE privacy_settings (
username VARCHAR(100) PRIMARY KEY REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
community_enabled BOOLEAN NOT NULL DEFAULT FALSE, -- DSGVO: Opt-in!
profile_visibility VARCHAR(20) NOT NULL DEFAULT 'FRIENDS', -- FRIENDS | ALL
show_achievements BOOLEAN NOT NULL DEFAULT TRUE,
show_stats BOOLEAN NOT NULL DEFAULT TRUE,
allow_requests BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE friendships (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
requester VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
addressee VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING | ACCEPTED
created_at TIMESTAMP NOT NULL,
responded_at TIMESTAMP,
CONSTRAINT uq_friend_pair UNIQUE (requester, addressee),
CONSTRAINT chk_no_self CHECK (requester <> addressee)
);
CREATE TABLE user_blocks (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
blocker VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
blocked VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMP NOT NULL,
CONSTRAINT uq_block_pair UNIQUE (blocker, blocked)
);
CREATE TABLE messages (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
sender VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
recipient VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
body VARCHAR(2000) NOT NULL,
sent_at TIMESTAMP NOT NULL,
read_at TIMESTAMP,
reported BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_messages_unread ON messages (recipient, read_at);
CREATE INDEX idx_messages_pair ON messages (sender, recipient, sent_at);
CREATE TABLE challenges (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
challenger VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
challenged VARCHAR(100) NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
game VARCHAR(50) NOT NULL,
deck VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'OPEN', -- OPEN | DONE | EXPIRED | DECLINED
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL
);
```
Eine Freundschaft zwischen A und B liegt genau einmal vor (Anfrage-Richtung = requester); Abfragen prüfen beide Richtungen (`(requester=?1 AND addressee=?2) OR (requester=?2 AND addressee=?1)`).
### 3.3 Backend (bewährtes Schichtmuster)
- **Services:** `FriendshipService` (anfragen/annehmen/ablehnen/entfernen), `MessageService` (senden, Verlauf paginiert laden, als gelesen markieren, eigene Nachricht löschen, melden), `BlockService` (blocken/entblocken, `isBlockedEitherWay(a, b)`), `PrivacyService` (Opt-in, Sichtbarkeiten).
- **Zentrale Guard-Methode**, die JEDE soziale Aktion durchläuft: Community aktiviert (beide Seiten)? Block in irgendeiner Richtung? Freundschaft bestätigt (für Nachrichten/Profile)? → sonst 403/404, ohne dem Angreifer zu verraten, ob der Zielnutzer existiert oder blockt.
- **Controller:** `SozialesController` (View `/soziales`, Freundesprofil `/soziales/profil/{username}`) + `@RestController /api/social/**` (Endpunkte: `GET /friends`, `POST /friends/request|accept|decline|remove`, `GET /messages/{friend}?after=`, `POST /messages`, `POST /messages/{id}/report`, `DELETE /messages/{id}`, `GET /unread-count`, `POST /block`, `DELETE /block/{username}`, `GET/PUT /privacy`). Route in die CSRF-Ignore-Liste der `SecurityConfig` aufnehmen (Muster der Spiel-APIs).
- **Moderation:** gemeldete Nachrichten landen in einer Admin-Ansicht (`/admin/fluxnet`) – der Admin sieht **ausschließlich gemeldete** Nachrichten (Verhältnismäßigkeit), kann löschen und Nutzer verwarnen/sperren.
### 3.4 Messenger-Transport: voll intern, keine externen Dienste
- **Phase 1 – Polling:** offener Chat pollt alle 5 s `GET /api/social/messages/{friend}?after=<letzteId>`, das Ungelesen-Badge alle 30 s `GET /api/social/unread-count`. Passt exakt zur zustandslosen Vanilla-JS-Architektur, braucht null neue Infrastruktur und skaliert für die Zielgruppe locker.
- **Phase 2 – Server-Sent Events:** `GET /api/social/stream` mit Spring `SseEmitter` (im `MessageService` wird bei `send()` an offene Emitter des Empfängers publiziert). Weiterhin 100 % selbst gehostet. nginx: `proxy_buffering off;` + langes `proxy_read_timeout` für den Stream-Pfad.
- **Bewusst NICHT:** WebSocket/STOMP (Overkill für 1:1-Chat) und Web-Push/FCM/Service-Worker-Push – Letzteres liefe zwingend über die Push-Infrastruktur der Browserhersteller (Google/Mozilla/Apple) und wäre damit ein externer Dienst.
### 3.5 DSGVO-Konzept
| Prinzip | Umsetzung |
|---------|-----------|
| **Opt-in (Art. 6, 25)** | `community_enabled = FALSE` als Default. Beim ersten Besuch von `/soziales`: Aktivierungsdialog mit klarem Hinweistext (welche Daten sichtbar werden, an wen). Ohne Aktivierung ist der Nutzer für andere unsichtbar und unauffindbar. Deaktivierung jederzeit in den Einstellungen (versteckt Profil, pausiert Eingang von Anfragen/Nachrichten). |
| **Datenminimierung (Art. 5)** | Nur Username-Referenzen und Nachrichtentexte – keine neuen Pflicht-Personendaten, keine Telefonnummern, keine Kontakte-Uploads. |
| **Privacy by Default (Art. 25)** | Sichtbarkeit ab Werk „nur Freunde"; Nachrichten nur zwischen bestätigten Freunden (verhindert nebenbei Spam). |
| **Löschung (Art. 17)** | Konto-Löschung kaskadiert per FK automatisch über Freundschaften, Blocks, Nachrichten und Challenges – nichts bleibt zurück. Einzelne eigene Nachrichten kann der Nutzer selbst löschen. |
| **Auskunft/Portabilität (Art. 15/20)** | Ausbau Phase 3: „Meine Daten exportieren"-Button (JSON-Dump der eigenen Profil-, Freundes-, Nachrichten- und Score-Daten). |
| **Vertraulichkeit (Art. 32)** | Transport via TLS (nginx). Speicherung serverseitig im Klartext in der eigenen Postgres – bewusste, dokumentierte Entscheidung (Moderationsfähigkeit); keine Weitergabe an Dritte, kein externer Dienst, keine Analytik. |
| **Transparenz (Art. 13)** | `datenschutz.html` um den Abschnitt FluxNet erweitern (Zwecke, Speicherdauer, Empfängerkreis „nur befreundete Nutzer", Moderationsregel); `nutzungsbedingungen.html` um Verhaltensregeln + Melde-/Sperrprozess. |
| **Betroffenenschutz** | Blockieren ist still (kein „X hat dich blockiert"); Melden einer Nachricht legt nur DIESE Nachricht dem Admin vor. |
### 3.6 Frontend
- `soziales.html` wird zum **FluxNet-Hub** (der Profil-Tab „Soziales" existiert schon): links Freundesliste (mit Online-Reihenfolge nach letzter Aktivität) + offene Anfragen + Username-Suche; rechts Chat-Panel des gewählten Freundes (Verlauf, Eingabefeld, „Nachricht melden/löschen" im Kontextmenü).
- Freundesprofil: Avatar, Level, Streak, Achievement-Vergleich (zwei Spalten, gemeinsame freigeschaltete hervorgehoben), Highscore-Tabelle je Spiel, Buttons „Herausfordern" / „Blockieren".
- Neue Dateien: `static/js/fluxnet.js` (IIFE, Polling-Logik, Render-Funktionen), `static/css/fluxnet.css` (Präfix `fn-`, nur Theme-Tokens, Erfolg blau ✓ / Fehler rot ✗). Ungelesen-Badge am Glocken-Icon der Dashboard-Topbar (zusammen mit dem FluxTimer-Popover aus Teil 4).
### 3.7 Phasen & Aufwand
1. **Phase 1 (≈ 7 PT):** Migrationen, Privacy-Opt-in, Freundschaften, Blockieren, Polling-Messenger, Ungelesen-Badge, Rechtstexte, Tests (Guard-Logik!).
2. **Phase 2 (≈ 4 PT):** SSE-Stream, Lern-Duelle, Freundesprofil mit Achievement-/Statistik-Vergleich, Melden + Admin-Moderationsansicht.
3. **Phase 3 (≈ 4 PT):** gemeinsame Lernräume (Seed-Boards), Datenexport (Art. 15/20), Feinschliff (Emoji-Reaktionen, Streak-Anstupsen).
---
## Teil 4: FluxTimer – Der Pausen-Lerntimer
> Opt-in-Erinnerungstimer im Look der Zeitschaltkreise des DeLorean: Nach frei wählbarer Zeit erinnert Mindflux ans Aufstehen, Trinken, Essen – oder was immer man einträgt. Standardmäßig **deaktiviert**, jederzeit abschaltbar, komplett frei einstellbar.
### 4.1 Funktionsumfang
- **Aktivieren/Deaktivieren** per Toggle (Einstellungen + Schnellzugriff über das Glocken-Icon der Dashboard-Topbar).
- **Intervall frei wählbar:** freie Minuteneingabe, sinnvolle Grenzen 5–240 Minuten (Default-Vorschlag 45).
- **Erinnerungsarten wählbar** (Mehrfachauswahl, Rotation): Aufstehen 🧍 · Trinken 💧 · Essen 🍎 · Augenpause 👀 · **eigener Text** (Freitextfeld).
- **Snooze** (+5 Minuten) und „Pause erledigt“ (startet das Intervall neu).
- **Probelauf-Button** in den Einstellungen (löst die Erinnerung sofort testweise aus).
- Optional zuschaltbar: Browser-Benachrichtigung (mit Permission-Abfrage) und dezenter Sound.
### 4.2 Technik: rein clientseitig, kein externer Dienst, DSGVO-minimal
- **`static/js/fluxtimer.js`** (IIFE), über das Layout-Fragment auf allen eingeloggten Seiten eingebunden.
- **Zustand in `localStorage`:** `fluxtimer = { enabled, intervalMinutes, reminders: ["aufstehen","trinken",…], customText, sound, notify, nextFireAt, lastFiredAt }`. Beim Seitenwechsel/Reload wird die Restzeit aus `nextFireAt` weitergeführt – der Timer „überlebt“ Navigation, ohne dass der Server irgendetwas weiß. **Kein Personenbezug, keine Serverdaten, nichts einwilligungspflichtig** (localStorage-Nutzung wird in der Datenschutzerklärung als technisch notwendig/funktional erwähnt).
- **Auslösung dreistufig:** (1) immer das In-App-Overlay (siehe 4.3); (2) optional lokale Browser-`Notification` – wird **lokal im Tab erzeugt**, kein Push-Server beteiligt; (3) optional kurzer Sound (Web Audio, eingebettet, kein CDN).
- **Bewusst kein Service-Worker-Web-Push:** Echte Push-Nachrichten bei geschlossenem Tab liefen zwingend über die Push-Gateways der Browserhersteller – das wäre ein externer Dienst und für einen Pausen-Timer unnötig (wenn kein Mindflux-Tab offen ist, lernt man gerade nicht in Mindflux).
- **Ausbau (optional, +0,5 PT):** `user_settings`-Tabelle für geräteübergreifende Einstellungen; localStorage bleibt der schnelle Cache.
### 4.3 BTTF-Optik („Zeitschaltkreise“)
- **Panel im Zeitschaltkreis-Look:** drei Anzeigezeilen in Segmentanzeigen-Typografie (Monospace + Glow):
- `NÄCHSTE PAUSE` – rot/bernsteinfarbene Anzeige (Countdown hh:mm:ss)
- `JETZT` – Akzentfarbe des Themes (laufende Uhrzeit)
- `LETZTE PAUSE` – gedimmt (wann zuletzt pausiert wurde)
- **Beim Auslösen:** kurzer Flux-Blitz (CSS-Animation über dem Overlay), 88-mph-Anspielung und Sprüche im BTTF-Ton, rotierend je Erinnerungsart – z. B. „Großer Scott! Zeit, etwas zu trinken.“ · „88 mph erreicht – kurz aufstehen, McFly!“ · „Dein Fluxkompensator braucht einen Snack.“
- **Styleguide-konform:** alle Farben über Theme-Tokens (die Rot-/Bernsteintöne als neue `--fluxtimer-*`-Tokens in `theme.css`, Dark/Light getestet); Grün bleibt tabu als Erfolgsfarbe. CSS in `static/css/fluxtimer.css` (Präfix `ft-`), Cache-Busting `?v=1`.
### 4.4 UI-Einstiegspunkte & Dateien
| Ort | Änderung |
|-----|----------|
| `settings.html` | Neue Sektion „FluxTimer“: Toggle, Minuten-Eingabe, Erinnerungs-Checkboxen, Freitext, Benachrichtigung/Sound-Optionen, Probelauf-Button |
| `dashboard.html` (Topbar) | Das bislang funktionslose Glocken-Icon öffnet ein FluxTimer-Popover: Status, Countdown, Schnell-Toggle, Link zu den Einstellungen |
| `templates/fragments/layout.html` | Script-Einbindung, damit der Timer auf allen eingeloggten Seiten weiterläuft |
| NEU `static/js/fluxtimer.js`, `static/css/fluxtimer.css` | Timer-Logik + Zeitschaltkreis-Optik |
### 4.5 Aufwand
≈ 1,5–2 PT (JS-Logik + Settings-UI + Popover + CSS/Animationen), +0,5 PT für die optionale serverseitige Persistenz.
---
## Schlüsseldateien als Bauplan-Referenz
| Datei | Rolle |
|-------|------|
| `src/main/java/com/example/mindflux/service/DevLabService.java` | Struktureller Bauplan für alle vier Services: `sanitize`/`check*`/`finish`/`awardedPoints`/`stars` + Text-Normalisierungs-Helfer (Checker für `choice`/`order`/`match`/`regex`/`cloze` teils 1:1 kopierbar) |
| `src/main/resources/static/js/devlabgame.js` | Renderer-Vertrag `{render, collect, lock, mark}`, Versuchsleiter-Frontend-Muster |
| `src/main/resources/db/migration/V8__devlab.sql` | SQL-Stilvorlage für V9–V12 |
| `src/main/java/com/example/mindflux/config/SecurityConfig.java` (Zeile 43) | CSRF-Ignore-Liste: `/api/codeescape/**`, `/api/bughunter/**`, `/api/serviceclash/**`, `/api/networker/**` ergänzen |
| `src/main/resources/templates/dashboard.html` (Zeilen 186–196) | `GAMES`-Array: Namen aufs neue Schema umstellen (DevLab, SyntaxSaga, CodeEscape, ServiceClash, BugHunter, NetWorker) und die vier `'/comingsoon'`-Hrefs gegen echte Routen tauschen (Icons `lock`/`sword`/`bug`/`network` existieren bereits) |
| `GameScoreController.KNOWN_GAMES` + `ProgressService.GAMES` | Vier reservierte Keys aufs neue Schema umbenennen (`codeescape`, `serviceclash`, `bughunter`, `networker`), `devlab` in `ProgressService` ergänzen (W1) |
| `src/main/java/com/example/mindflux/service/GameScoreService.java` | `recordScore(...)`-Anschlusspunkt aller Spiele |
| `STYLEGUIDE.md`, `CHANGELOG.md` | Design-Regeln (blau ✓ / rot ✗, nie grün; Theme-Tokens; `?v=N`) und Pflege der Historie |
## Verifikation
**Pro Spiel (nach Implementierung):**
1. `mvn test` – insbesondere `NetSimulatorTest` (Same-Subnet, Gateway, Longest-Prefix-Routen, Loop-Guard, Netz-/Broadcast-Adresse, Duplikat-IP) und `ServiceClashServiceTest` (Seed-Determinismus: gleicher Seed ⇒ identischer Plan; Re-Simulations-Scoring).
2. App starten (`docker-compose` oder lokal, Port 8282), einloggen und den vollen Spielfluss durchspielen: Lobby → Runde → absichtlich falsche Antworten (Versuchsleiter 1.0/0.6/0.3 prüfen) → Aufdecken (0 Punkte) → Abschluss → Score/Sterne/Highscore-Anzeige.
3. Server-Autorität prüfen: `POST /finish` mit manipulierten Daten (fremde IDs, unmögliche Zeitstempel) darf keinen überhöhten Score ergeben; View-DTOs im Netzwerk-Tab dürfen keine Lösungsfelder enthalten.
4. Highscore-Chip via `GET /api/games/<key>/stats`; Eintrag erscheint auf `/progress`.
5. Admin-CRUD: Anlegen/Ändern/Löschen inkl. Payload-Validierung (Netzwerker: Musterlösungs-Gate; Bug Hunter: `file`/`line`-Konsistenz; Service Clash: `correctIndex` in `options`).
6. Dark/Light-Theme umschalten und mobile Breite prüfen (Theme-Tokens, kein Grün).
**Namensschema & visuelle Differenzierung:** Dashboard zeigt alle neun Spiele mit einheitlichen CamelCase-Namen; jede der vier neuen Spielseiten hat ihren eigenen Akzent und ihr eigenes visuelles Vokabular – nebeneinander geöffnet müssen sie sofort unterscheidbar sein (Dark und Light prüfen).
**Navigation (Abschnitt 2.3):** Von jeder Profil-Unterseite aus sind alle anderen per Tab-Leiste erreichbar; „Profil" ist als Sidebar-Eintrag vorhanden; `settings.html` enthält keine funktionslosen Bedienelemente mehr; kein irreführender Chevron ohne Dropdown.
**Für die Verbesserungen (Teil 2):** Nach K1/K2: Neustart via docker-compose mit Secret-basiertem Passwort; `/users/save` liefert 404. Nach W1: DevLab erscheint auf `/progress`. Nach W4: bewusst kaputte Requests → saubere Fehlerseite/JSON-Fehler statt Whitelabel-500. Nach W6: doppelte Registrierung (Groß-/Kleinschreibung) liefert Fehlermeldung statt 500.
+17 -4
View File
@@ -19,7 +19,8 @@
<description>Mindflux – Lernplattform zur Prüfungsvorbereitung</description> <description>Mindflux – Lernplattform zur Prüfungsvorbereitung</description>
<properties> <properties>
<java.version>17</java.version> <!-- Angeglichen an die Dockerfile-Laufzeit (Temurin 21) -->
<java.version>21</java.version>
</properties> </properties>
<dependencies> <dependencies>
@@ -53,10 +54,16 @@
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<!-- Passwort-Verschlüsselung (BCrypt) --> <!-- Bean Validation (@Valid, @NotBlank, @Email ...) -->
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-security-crypto</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Health/Monitoring (Container-Healthcheck auf /actuator/health) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<!-- PostgreSQL Datenbanktreiber --> <!-- PostgreSQL Datenbanktreiber -->
@@ -91,6 +98,12 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
+89
View File
@@ -0,0 +1,89 @@
# Anleitung: DB-Passwort rotieren & Gitea-Secret anlegen
> **Status 2026-07-12:** In Produktion abgeschlossen. Das Secret ist angelegt, die DB-Rolle
> wurde rotiert und Workflow 43 erfolgreich ausgeführt. Diese Anleitung bleibt als Betriebs-
> und Notfalldokumentation erhalten.
**Warum?** Das alte Datenbank-Passwort `mindflux_pass` stand im Klartext in `docker-compose.yml` – und bleibt über die Git-Historie für immer einsehbar. Es gilt daher als **kompromittiert** und muss ersetzt werden.
Die Technik ist bereits vorbereitet:
- `docker-compose.yml` liest das Passwort jetzt aus `${MINDFLUX_DB_PASSWORD}` (kein Klartext mehr im Repo).
- Der Gitea-Workflow (`.gitea/workflows/mindflux.yml`) reicht das Secret `MINDFLUX_DB_PASSWORD` beim Deploy durch – genau wie die Admin-Passwörter.
**Wichtig:** Bis das Secret angelegt ist, bricht das nächste Deployment mit der Meldung
`MINDFLUX_DB_PASSWORD muss als Secret gesetzt sein` ab. So wird kein App-Container mit
einem leeren Datenbank-Passwort gestartet. Die folgenden 5 Schritte dauern ca. 5 Minuten.
---
## Schritt 1: Starkes Passwort erzeugen
PowerShell (Windows):
```powershell
-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
```
Oder auf dem Server (Linux):
```bash
openssl rand -base64 24
```
Das Ergebnis kopieren – es wird in Schritt 2 **und** Schritt 3 gebraucht. Nirgendwo einchecken, nicht in Dateien im Repo speichern.
## Schritt 2: Gitea-Secret anlegen
1. Gitea öffnen → das **mindflux-Repository** aufrufen.
2. **Einstellungen** → **Actions** → **Secrets** (derselbe Ort, an dem schon `MINDFLUX_ADMIN_PASSWORD` und `MINDFLUX_ADMIN2_PASSWORD` liegen).
3. **„Neues Secret hinzufügen"**:
- **Name:** `MINDFLUX_DB_PASSWORD` (exakt so – der Workflow referenziert diesen Namen)
- **Wert:** das neue Passwort aus Schritt 1
4. Speichern.
## Schritt 3: Passwort in der Produktions-Datenbank ändern
Der Gitea-Workflow gleicht das Passwort vor jedem Deployment automatisch mit dem Secret ab.
Beim ersten Deployment nach der Rotation führt der Runner das entsprechende `ALTER USER` aus;
ein separater SSH-Zugang ist deshalb nicht erforderlich.
Der folgende Befehl ist nur noch ein manueller Notfallweg, falls der Runner-Schritt einmal
nicht verfügbar sein sollte:
Auf dem Server (dort, wo der Postgres-Container läuft – er heißt im Backend-Netz `postgres`):
```bash
docker exec -it postgres psql -U mindflux -d mindfluxdb -c "ALTER USER mindflux WITH PASSWORD 'HIER_DAS_NEUE_PASSWORT';"
```
> Falls der Container anders heißt: mit `docker ps` nachsehen. DB-User ist `mindflux`, Datenbank `mindfluxdb` (siehe `docker-compose.yml`).
>
> Achtung bei Sonderzeichen: Das Passwort steht in einfachen Anführungszeichen im SQL – ein `'` im Passwort müsste verdoppelt werden (`''`). Am einfachsten: Passwort ohne `'` erzeugen (die Generatoren aus Schritt 1 tun das).
## Schritt 4: Deployment auslösen
Einfach einen Push auf `main` machen (oder den Workflow in Gitea manuell erneut ausführen). Der Deploy-Schritt exportiert das Secret und startet den Container neu:
```bash
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
```
> Zwischen Schritt 3 und dem Neustart gibt es ein kurzes Fenster, in dem der noch laufende alte Container seine bestehenden DB-Verbindungen behält – das ist unkritisch. Sollte die App nach dem Neustart nicht hochkommen, prüfen ob Secret-Wert (Schritt 2) und DB-Passwort (Schritt 3) wirklich identisch sind, dann erneut `docker compose up -d`.
## Schritt 5: Verifizieren
```bash
docker ps # Container "mindflux" muss nach ~40 s "healthy" zeigen (Actuator-Healthcheck)
docker logs mindflux --tail 30 # keine "password authentication failed"-Fehler
```
Danach im Browser einloggen – wenn Login und Dashboard funktionieren, ist die Rotation abgeschlossen. ✅
---
## Hinweise
- **Lokale Entwicklung:** lokal wird das Passwort über die Umgebungsvariable `MINDFLUX_DB_PASSWORD` bzw. eine (nicht eingecheckte) `.env`-Datei gesetzt (`application.properties` importiert sie automatisch). Die lokale Datenbank kann ein eigenes, anderes Passwort haben.
- **Warum die Historie nicht bereinigen?** Das alte Passwort ließe sich nur per Force-Push/History-Rewrite aus Git entfernen – aufwendig und riskant. Rotation ist der richtige Weg: Das alte Passwort darf ruhig bekannt sein, wenn es nirgends mehr gilt.
- Die Postgres-Instanz ist nur im internen `backend_network` erreichbar (kein öffentlicher Port) – die Rotation ist trotzdem Pflicht (Defense in Depth).
@@ -1,12 +0,0 @@
package com.example.mindflux;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class AppStartCheck implements CommandLineRunner {
@Override
public void run(String... args) {
System.out.println("✅ App läuft. Sicherheitsmodul sollte aktiv sein.");
}
}
@@ -2,6 +2,8 @@ package com.example.mindflux.config;
import com.example.mindflux.model.User; import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository; import com.example.mindflux.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -11,6 +13,8 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration @Configuration
public class AdminSetup { public class AdminSetup {
private static final Logger log = LoggerFactory.getLogger(AdminSetup.class);
@Value("${MINDFLUX_ADMIN_USERNAME:admin}") @Value("${MINDFLUX_ADMIN_USERNAME:admin}")
private String adminUsername; private String adminUsername;
@@ -44,7 +48,7 @@ public class AdminSetup {
return; // Admin-Slot nicht konfiguriert -> still überspringen. return; // Admin-Slot nicht konfiguriert -> still überspringen.
} }
if (password == null || password.isBlank()) { if (password == null || password.isBlank()) {
System.out.println("Kein Bootstrap-Admin erstellt: Passwort für '" + username + "' ist nicht gesetzt."); log.warn("Kein Bootstrap-Admin erstellt: Passwort für '{}' ist nicht gesetzt.", username);
return; return;
} }
if (userRepo.findByUsername(username).isEmpty()) { if (userRepo.findByUsername(username).isEmpty()) {
@@ -54,7 +58,7 @@ public class AdminSetup {
admin.setPassword(passwordEncoder.encode(password)); admin.setPassword(passwordEncoder.encode(password));
admin.setRole("ADMIN"); admin.setRole("ADMIN");
userRepo.save(admin); userRepo.save(admin);
System.out.println("Bootstrap-Admin erstellt: " + username); log.info("Bootstrap-Admin erstellt: {}", username);
} }
} }
} }
@@ -0,0 +1,28 @@
package com.example.mindflux.config;
import com.example.mindflux.service.LoginAttemptService;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;
/** Füttert den Login-Rate-Limiter mit den Auth-Ereignissen von Spring Security. */
@Component
public class AuthenticationEventListener {
private final LoginAttemptService loginAttemptService;
public AuthenticationEventListener(LoginAttemptService loginAttemptService) {
this.loginAttemptService = loginAttemptService;
}
@EventListener
public void onFailure(AuthenticationFailureBadCredentialsEvent event) {
loginAttemptService.loginFailed(event.getAuthentication().getName());
}
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
loginAttemptService.loginSucceeded(event.getAuthentication().getName());
}
}
@@ -5,6 +5,8 @@ import com.example.mindflux.model.DevLabTask;
import com.example.mindflux.repository.DevLabGameRepository; import com.example.mindflux.repository.DevLabGameRepository;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -20,6 +22,8 @@ import java.util.Set;
@Component @Component
public class DevLabSeeder implements CommandLineRunner { public class DevLabSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DevLabSeeder.class);
private static final int MAX_PAYLOAD = 8000; private static final int MAX_PAYLOAD = 8000;
private final DevLabGameRepository gameRepository; private final DevLabGameRepository gameRepository;
@@ -40,7 +44,7 @@ public class DevLabSeeder implements CommandLineRunner {
JsonNode root = objectMapper.readTree(new ClassPathResource("devlab/content.json").getInputStream()); JsonNode root = objectMapper.readTree(new ClassPathResource("devlab/content.json").getInputStream());
JsonNode games = root.get("games"); JsonNode games = root.get("games");
if (games == null || !games.isArray()) { if (games == null || !games.isArray()) {
System.out.println("⚠️ Dev-Lab-Seed: content.json ohne 'games'."); log.warn("Dev-Lab-Seed: content.json ohne 'games'.");
return; return;
} }
@@ -87,10 +91,10 @@ public class DevLabSeeder implements CommandLineRunner {
gameCount++; gameCount++;
} }
} }
System.out.println("✅ Dev Lab: " + gameCount + " Spiele / " + taskCount + " Aufgaben geladen" log.info("DevLab: {} Spiele / {} Aufgaben geladen{}.", gameCount, taskCount,
+ (skipped > 0 ? " (" + skipped + " zu groß, übersprungen)" : "") + "."); skipped > 0 ? " (" + skipped + " zu groß, übersprungen)" : "");
} catch (Exception e) { } catch (Exception e) {
System.out.println("⚠️ Dev-Lab-Seed übersprungen: " + e.getMessage()); log.warn("Dev-Lab-Seed übersprungen: {}", e.getMessage(), e);
} }
} }
@@ -2,15 +2,16 @@ package com.example.mindflux.config;
import com.example.mindflux.model.User; import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository; import com.example.mindflux.repository.UserRepository;
import com.example.mindflux.service.LoginAttemptService;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.*; import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration @Configuration
public class SecurityConfig { public class SecurityConfig {
@@ -20,20 +21,21 @@ public class SecurityConfig {
http http
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll() .requestMatchers("/", "/login", "/register", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") // NEU: Adminbereich absichern .requestMatchers("/actuator/health").permitAll() // Container-Healthcheck
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasRole("ADMIN") .requestMatchers("/api/users/**").hasRole("ADMIN")
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.formLogin(form -> form .formLogin(form -> form
.loginPage("/") // login.html liegt auf "/" .loginPage("/") // Login-Formular liegt auf der Startseite (index.html)
.loginProcessingUrl("/login") .loginProcessingUrl("/login")
.defaultSuccessUrl("/dashboard", true) .defaultSuccessUrl("/dashboard", true)
.failureUrl("/?error") // zeigt Loginfehler an .failureUrl("/?error") // zeigt Loginfehler an
.permitAll() .permitAll()
) )
.logout(logout -> logout .logout(logout -> logout
// Logout-Links in den Templates sind GET -> GET-Logout erlauben, sonst 404/Fehler unter CSRF // Logout nur per POST (CSRF-geschützt); die Templates nutzen kleine Formulare.
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")) .logoutUrl("/logout")
.logoutSuccessUrl("/?logout") .logoutSuccessUrl("/?logout")
.permitAll() .permitAll()
) )
@@ -47,8 +49,13 @@ public class SecurityConfig {
} }
@Bean @Bean
public UserDetailsService userDetailsService(UserRepository userRepo) { public UserDetailsService userDetailsService(UserRepository userRepo, LoginAttemptService loginAttemptService) {
return username -> { return username -> {
// Brute-Force-Schutz: Nach zu vielen Fehlversuchen vorübergehend sperren.
if (loginAttemptService.isBlocked(username)) {
throw new LockedException("Zu viele Fehlversuche – bitte später erneut versuchen.");
}
User user = userRepo.findByUsernameIgnoreCase(username) User user = userRepo.findByUsernameIgnoreCase(username)
.orElseThrow(() -> new UsernameNotFoundException("Benutzer nicht gefunden")); .orElseThrow(() -> new UsernameNotFoundException("Benutzer nicht gefunden"));
@@ -6,6 +6,8 @@ import com.example.mindflux.model.SyntaxSagaSolution;
import com.example.mindflux.repository.SyntaxSagaMissionRepository; import com.example.mindflux.repository.SyntaxSagaMissionRepository;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -22,6 +24,8 @@ import java.util.Set;
@Component @Component
public class SyntaxSagaSeeder implements CommandLineRunner { public class SyntaxSagaSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(SyntaxSagaSeeder.class);
private static final Set<String> DIFFICULTIES = Set.of("junior", "professional", "senior"); private static final Set<String> DIFFICULTIES = Set.of("junior", "professional", "senior");
private final SyntaxSagaMissionRepository missionRepository; private final SyntaxSagaMissionRepository missionRepository;
@@ -43,7 +47,7 @@ public class SyntaxSagaSeeder implements CommandLineRunner {
new ClassPathResource("syntaxsaga/content.json").getInputStream()); new ClassPathResource("syntaxsaga/content.json").getInputStream());
JsonNode missions = root.get("missions"); JsonNode missions = root.get("missions");
if (missions == null || !missions.isArray()) { if (missions == null || !missions.isArray()) {
System.out.println("⚠️ Syntax-Saga-Seed: content.json ohne 'missions'."); log.warn("Syntax-Saga-Seed: content.json ohne 'missions'.");
return; return;
} }
@@ -104,9 +108,9 @@ public class SyntaxSagaSeeder implements CommandLineRunner {
missionCount++; missionCount++;
} }
} }
System.out.println("✅ Syntax Saga: " + missionCount + " Missionen / " + gapCount + " Lücken geladen."); log.info("SyntaxSaga: {} Missionen / {} Lücken geladen.", missionCount, gapCount);
} catch (Exception e) { } catch (Exception e) {
System.out.println("⚠️ Syntax-Saga-Seed übersprungen: " + e.getMessage()); log.warn("Syntax-Saga-Seed übersprungen: {}", e.getMessage(), e);
} }
} }
@@ -6,6 +6,8 @@ import com.example.mindflux.model.TechtacularQuestion;
import com.example.mindflux.repository.TechtacularCategoryRepository; import com.example.mindflux.repository.TechtacularCategoryRepository;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -22,6 +24,8 @@ import java.util.Set;
@Component @Component
public class TechtacularSeeder implements CommandLineRunner { public class TechtacularSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(TechtacularSeeder.class);
private final TechtacularCategoryRepository categoryRepository; private final TechtacularCategoryRepository categoryRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -40,7 +44,7 @@ public class TechtacularSeeder implements CommandLineRunner {
JsonNode root = objectMapper.readTree(new ClassPathResource("techtacular/content.json").getInputStream()); JsonNode root = objectMapper.readTree(new ClassPathResource("techtacular/content.json").getInputStream());
JsonNode categories = root.get("categories"); JsonNode categories = root.get("categories");
if (categories == null || !categories.isArray()) { if (categories == null || !categories.isArray()) {
System.out.println("⚠️ TechTacular-Seed: content.json ohne 'categories'."); log.warn("TechTacular-Seed: content.json ohne 'categories'.");
return; return;
} }
@@ -95,9 +99,9 @@ public class TechtacularSeeder implements CommandLineRunner {
catCount++; catCount++;
} }
} }
System.out.println("✅ TechTacular: " + catCount + " Kategorien / " + clueCount + " Hinweise geladen."); log.info("TechTacular: {} Kategorien / {} Hinweise geladen.", catCount, clueCount);
} catch (Exception e) { } catch (Exception e) {
System.out.println("⚠️ TechTacular-Seed übersprungen: " + e.getMessage()); log.warn("TechTacular-Seed übersprungen: {}", e.getMessage(), e);
} }
} }
@@ -19,18 +19,8 @@ public class AchievementController {
@GetMapping("/profile/achievements") @GetMapping("/profile/achievements")
public String showAchievements(Model model, Principal principal) { public String showAchievements(Model model, Principal principal) {
String username = principal.getName(); String username = principal.getName();
// Kompletter Katalog: verdiente Achievements mit Datum, restliche als gesperrte Karten.
List<Achievement> achievements = achievementService.getAchievementsForUser(username); List<Achievement> achievements = achievementService.getAchievementsForUser(username);
// 🔥 Dummy-Daten nur für Tests!
if (achievements == null || achievements.isEmpty()) {
achievements = List.of(
new Achievement("Hangman Hero", "Gewinne 10 Runden Hangman", "hangman.png", true),
new Achievement("Memory Meister", "Erreiche 1000 Punkte im Memory", "memory.png", false),
new Achievement("Quizkönig", "Beantworte 50 Fragen richtig", "quiz.png", true),
new Achievement("Early Bird", "Lerne vor 7 Uhr morgens", "earlybird.png", false)
);
}
model.addAttribute("achievements", achievements); model.addAttribute("achievements", achievements);
return "achievements"; // lädt achievements.html return "achievements"; // lädt achievements.html
@@ -1,8 +1,7 @@
package com.example.mindflux.controller; package com.example.mindflux.controller;
import com.example.mindflux.model.User; import com.example.mindflux.model.User;
import com.example.mindflux.service.AdminUserService; import com.example.mindflux.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
@@ -12,16 +11,18 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/admin") @RequestMapping("/admin")
public class AdminUserController { public class AdminUserController {
@Autowired private final UserService userService;
private AdminUserService userService; private final PasswordEncoder passwordEncoder;
@Autowired public AdminUserController(UserService userService, PasswordEncoder passwordEncoder) {
private PasswordEncoder passwordEncoder; this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
// ⬅️ Neu: Dashboard-Startseite // ⬅️ Dashboard-Startseite
@GetMapping("") @GetMapping("")
public String adminDashboard() { public String adminDashboard() {
return "admin/dashboard"; // du brauchst: templates/admin/dashboard.html return "admin/dashboard";
} }
// ⬅️ Benutzerverwaltung // ⬅️ Benutzerverwaltung
@@ -42,6 +43,8 @@ public class AdminUserController {
if (user.getId() != null) { if (user.getId() != null) {
User existingUser = userService.findById(user.getId()) User existingUser = userService.findById(user.getId())
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + user.getId())); .orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + user.getId()));
userService.assertUsernameAvailable(user.getUsername(), user.getId());
user.setRole(userService.validateRole(user.getRole()));
// Passwort: leer = unverändert übernehmen, sonst neu verschlüsseln (Admin-Reset) // Passwort: leer = unverändert übernehmen, sonst neu verschlüsseln (Admin-Reset)
if (user.getPassword() == null || user.getPassword().isBlank()) { if (user.getPassword() == null || user.getPassword().isBlank()) {
user.setPassword(existingUser.getPassword()); user.setPassword(existingUser.getPassword());
@@ -50,11 +53,11 @@ public class AdminUserController {
} }
// Profilbild steht nicht im Formular -> bestehendes erhalten, nicht überschreiben // Profilbild steht nicht im Formular -> bestehendes erhalten, nicht überschreiben
user.setProfilePicture(existingUser.getProfilePicture()); user.setProfilePicture(existingUser.getProfilePicture());
} else {
user.setPassword(passwordEncoder.encode(user.getPassword()));
}
userService.save(user); userService.save(user);
} else {
// Neuanlage zentral über den UserService (Validierung, Eindeutigkeit, Encoding)
userService.createUser(user.getUsername(), user.getEmail(), user.getPassword(), user.getRole());
}
return "redirect:/admin/users"; return "redirect:/admin/users";
} }
@@ -72,7 +75,8 @@ public class AdminUserController {
@GetMapping("/users/edit/{id}") @GetMapping("/users/edit/{id}")
public String editUserForm(@PathVariable Long id, Model model) { public String editUserForm(@PathVariable Long id, Model model) {
User user = userService.findById(id).orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id)); User user = userService.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
model.addAttribute("user", user); model.addAttribute("user", user);
return "admin/users/form"; return "admin/users/form";
} }
@@ -0,0 +1,75 @@
package com.example.mindflux.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Fehlerbehandlung für alle JSON-APIs: liefert statt Whitelabel-HTML einen
* konsistenten JSON-Körper mit korrektem Statuscode.
*/
@RestControllerAdvice(annotations = RestController.class)
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handleStatus(ResponseStatusException e) {
return body(e.getStatusCode().value(), e.getReason());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException e) {
return body(400, e.getMessage());
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, Object>> handleUnreadable(HttpMessageNotReadableException e) {
return body(400, "Ungültiger Request-Body.");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException e) {
String details = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + fieldMessage(f))
.collect(Collectors.joining("; "));
return body(400, details.isBlank() ? "Validierung fehlgeschlagen." : details);
}
/** Security-Fehler NICHT schlucken – die behandelt Spring Security selbst (403/Login). */
@ExceptionHandler(AccessDeniedException.class)
public void rethrowAccessDenied(AccessDeniedException e) {
throw e;
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleServerError(Exception e) {
log.error("Unbehandelter API-Fehler", e);
return body(500, "Interner Fehler. Bitte später erneut versuchen.");
}
private static String fieldMessage(FieldError f) {
return f.getDefaultMessage() == null ? "ungültig" : f.getDefaultMessage();
}
private ResponseEntity<Map<String, Object>> body(int status, String message) {
return ResponseEntity.status(status).body(Map.of(
"error", true,
"status", status,
"message", message == null || message.isBlank() ? "Fehler" : message));
}
}
@@ -1,13 +1,25 @@
package com.example.mindflux.controller; package com.example.mindflux.controller;
import com.example.mindflux.service.ProgressService;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
@Controller @Controller
public class DashboardController { public class DashboardController {
private final ProgressService progressService;
public DashboardController(ProgressService progressService) {
this.progressService = progressService;
}
@GetMapping("/dashboard") @GetMapping("/dashboard")
public String showDashboard() { public String showDashboard(Model model, Principal principal) {
return "dashboard"; // lädt dashboard.html aus templates // Echte XP/Level/Streak-Werte für die Sidebar-Widgets (statt Dummy-Daten im Template).
model.addAttribute("overall", progressService.getOverallProgress(principal.getName()));
return "dashboard";
} }
} }
@@ -21,8 +21,8 @@ public class GameScoreController {
/** Bekannte Spiele (entsprechen den Dashboard-Routen). */ /** Bekannte Spiele (entsprechen den Dashboard-Routen). */
private static final Set<String> KNOWN_GAMES = Set.of( private static final Set<String> KNOWN_GAMES = Set.of(
"mindmatch", "techtacular", "syntax-saga", "devlab", "escape-room", "mindmatch", "techtacular", "syntax-saga", "devlab", "codeescape",
"service-clash", "bug-hunter", "netzwerker"); "serviceclash", "bughunter", "networker");
private final GameScoreService gameScoreService; private final GameScoreService gameScoreService;
@@ -0,0 +1,60 @@
package com.example.mindflux.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* Fängt Fehler aus den HTML-Controllern ab und zeigt eine gestaltete Fehlerseite
* statt der Whitelabel-Seite. Die JSON-APIs behandelt der ApiExceptionHandler
* (höhere Präzedenz, greift nur für @RestController).
*/
@ControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** Ungültige IDs/Parameter (z. B. Admin-Formulare) -> 400 mit lesbarer Meldung. */
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public String handleBadRequest(IllegalArgumentException e, Model model) {
model.addAttribute("status", 400);
model.addAttribute("friendlyMessage",
e.getMessage() == null ? "Ungültige Anfrage." : e.getMessage());
return "error";
}
/** Unbekannte URL -> echte 404-Seite (darf nicht im Catch-all als 500 landen). */
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public String handleNotFound(NoResourceFoundException e, Model model) {
model.addAttribute("status", 404);
model.addAttribute("friendlyMessage", "Diese Seite existiert nicht (mehr).");
return "error";
}
/** Security-Fehler NICHT schlucken – die behandelt Spring Security selbst (403/Login). */
@ExceptionHandler(AccessDeniedException.class)
public void rethrowAccessDenied(AccessDeniedException e) {
throw e;
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public String handleServerError(Exception e, Model model) {
log.error("Unbehandelter Fehler", e);
model.addAttribute("status", 500);
model.addAttribute("friendlyMessage",
"Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.");
return "error";
}
}
@@ -2,6 +2,8 @@ package com.example.mindflux.controller;
import com.example.mindflux.model.User; import com.example.mindflux.model.User;
import com.example.mindflux.service.UserService; import com.example.mindflux.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -29,6 +31,8 @@ import java.util.UUID;
@Controller @Controller
public class ProfileController { public class ProfileController {
private static final Logger log = LoggerFactory.getLogger(ProfileController.class);
@Autowired @Autowired
private UserService userService; private UserService userService;
@@ -61,19 +65,17 @@ public class ProfileController {
if (profilePicture != null && !profilePicture.trim().isEmpty()) { if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try { try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture); Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
System.out.println("Versuche zu löschen: " + picturePath.toAbsolutePath());
if (Files.exists(picturePath)) { if (Files.exists(picturePath)) {
Files.delete(picturePath); Files.delete(picturePath);
System.out.println("Datei erfolgreich gelöscht: " + picturePath.getFileName()); log.info("Profilbild gelöscht: {}", picturePath.getFileName());
} else { } else {
System.out.println("Datei existiert nicht: " + picturePath.toAbsolutePath()); log.warn("Profilbild-Datei existiert nicht: {}", picturePath.toAbsolutePath());
} }
} catch (IOException e) { } catch (IOException e) {
System.err.println("Fehler beim Löschen der Datei: " + e.getMessage()); log.error("Fehler beim Löschen der Profilbild-Datei", e);
} }
} else { } else {
System.out.println("Kein Profilbild gesetzt, nichts zu löschen."); log.debug("Kein Profilbild gesetzt, nichts zu löschen.");
} }
existingUser.setProfilePicture(null); // Wichtig: auch wenn Datei nicht existiert existingUser.setProfilePicture(null); // Wichtig: auch wenn Datei nicht existiert
@@ -83,6 +85,12 @@ public class ProfileController {
return "redirect:/profile?success=true"; return "redirect:/profile?success=true";
} }
/** Einstellungen gehören zum Profilbereich – eine Zuständigkeit, ein Controller. */
@GetMapping("/profile/settings")
public String settings() {
return "settings";
}
@GetMapping("/profile/knowledge") @GetMapping("/profile/knowledge")
public String knowledgeCenter(Model model, Principal principal) { public String knowledgeCenter(Model model, Principal principal) {
String username = principal.getName(); String username = principal.getName();
@@ -107,7 +115,7 @@ public class ProfileController {
BufferedImage original = ImageIO.read(file.getInputStream()); BufferedImage original = ImageIO.read(file.getInputStream());
if (original == null) { if (original == null) {
// Keine lesbare Bilddatei -> Upload ignorieren, restliches Profil trotzdem speichern. // Keine lesbare Bilddatei -> Upload ignorieren, restliches Profil trotzdem speichern.
System.out.println("Profilbild-Upload ignoriert: keine lesbare Bilddatei."); log.warn("Profilbild-Upload ignoriert: keine lesbare Bilddatei.");
} else { } else {
// Auf max. Kantenlänge herunterskalieren (kein Hochskalieren). // Auf max. Kantenlänge herunterskalieren (kein Hochskalieren).
final int MAX_DIM = 400; final int MAX_DIM = 400;
@@ -141,7 +149,7 @@ public class ProfileController {
existingUser.setProfilePicture(fileName); existingUser.setProfilePicture(fileName);
} }
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); log.error("Fehler beim Speichern des Profilbilds", e);
} }
} }
@@ -189,7 +197,7 @@ public class ProfileController {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture); Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
Files.deleteIfExists(picturePath); Files.deleteIfExists(picturePath);
} catch (IOException e) { } catch (IOException e) {
System.err.println("Fehler beim Löschen des Profilbilds: " + e.getMessage()); log.error("Fehler beim Löschen des Profilbilds", e);
} }
} }
} }
@@ -198,12 +206,4 @@ public class ProfileController {
new SecurityContextLogoutHandler().logout(request, response, authentication); new SecurityContextLogoutHandler().logout(request, response, authentication);
return "redirect:/?logout"; return "redirect:/?logout";
} }
// Hilfsmethode: Dateiendung extrahieren
private String getFileExtension(String filename) {
if (filename == null || filename.lastIndexOf(".") == -1) {
return "";
}
return filename.substring(filename.lastIndexOf("."));
}
} }
@@ -1,51 +1,39 @@
package com.example.mindflux.controller; package com.example.mindflux.controller;
import com.example.mindflux.model.User; import com.example.mindflux.service.RegistrationResult;
import com.example.mindflux.service.UserService; import com.example.mindflux.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller @Controller
public class RegisterController { public class RegisterController {
@Autowired private final UserService userService;
private UserService userService;
@Autowired public RegisterController(UserService userService) {
private PasswordEncoder passwordEncoder; this.userService = userService;
}
@GetMapping("/register") @GetMapping("/register")
public String showRegistrationForm(Model model) { public String showRegistrationForm() {
model.addAttribute("user", new User()); // Die eigenständige Registrierungsseite wurde entfernt – das Formular lebt auf der Startseite.
return "register"; return "redirect:/";
} }
@PostMapping("/register") @PostMapping("/register")
public String processRegistration(@ModelAttribute("user") User user, public String processRegistration(@RequestParam String username,
@RequestParam(value = "confirmPassword", required = false) String confirmPassword) { @RequestParam String email,
// Schreibweise des Benutzernamens beibehalten, nur trimmen @RequestParam String password,
String username = user.getUsername() == null ? "" : user.getUsername().trim(); @RequestParam(required = false) String confirmPassword) {
user.setUsername(username); RegistrationResult result = userService.register(username, email, password, confirmPassword);
return switch (result) {
// Passwörter müssen übereinstimmen (Schutz vor Tippfehlern bei der Eingabe) case SUCCESS -> "redirect:/?registerSuccess";
if (user.getPassword() == null || user.getPassword().isBlank() case PASSWORD_MISMATCH -> "redirect:/?passwordMismatch";
|| !user.getPassword().equals(confirmPassword)) { case PASSWORD_TOO_SHORT -> "redirect:/?passwordPolicy";
return "redirect:/?passwordMismatch"; case EMAIL_INVALID -> "redirect:/?emailInvalid";
} case USERNAME_INVALID, USERNAME_TAKEN -> "redirect:/?registerError";
};
// Benutzername schon vergeben? (case-insensitiv, damit "Alice" und "alice" nicht kollidieren)
if (username.isBlank() || userService.findByUsernameIgnoreCase(username) != null) {
return "redirect:/?registerError";
}
// Passwort verschlüsseln und speichern
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setRole("USER"); // Standardrolle
userService.save(user);
return "redirect:/?registerSuccess"; // zurück zum Login mit Erfolgsmeldung
} }
} }
@@ -1,13 +0,0 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SettingsController {
@GetMapping("/profile/settings")
public String showSettingsPage() {
return "settings"; // lädt templates/settings.html
}
}
@@ -1,67 +1,72 @@
package com.example.mindflux.controller; package com.example.mindflux.controller;
import com.example.mindflux.model.User; import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository; import com.example.mindflux.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api/users")
public class UserController { public class UserController {
@Autowired private final UserService userService;
private UserRepository userRepository; private final PasswordEncoder passwordEncoder;
@Autowired public UserController(UserService userService, PasswordEncoder passwordEncoder) {
private PasswordEncoder passwordEncoder; this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
// 📥 User erstellen // 📥 User erstellen (Validierung, Eindeutigkeit und Encoding zentral im UserService)
@PostMapping @PostMapping
public User createUser(@RequestBody User user) { public User createUser(@RequestBody User user) {
user.setPassword(passwordEncoder.encode(user.getPassword())); return userService.createUser(user.getUsername(), user.getEmail(), user.getPassword(), user.getRole());
return userRepository.save(user);
} }
// 📋 Alle User anzeigen // 📋 Alle User anzeigen
@GetMapping @GetMapping
public List<User> getAllUsers() { public List<User> getAllUsers() {
return userRepository.findAll(); return userService.findAll();
} }
// 📍 Einzelnen User anzeigen // 📍 Einzelnen User anzeigen
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) { public ResponseEntity<User> getUserById(@PathVariable Long id) {
Optional<User> user = userRepository.findById(id); return userService.findById(id)
return user.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build()); .orElseGet(() -> ResponseEntity.notFound().build());
} }
// ✏️ User aktualisieren // ✏️ User aktualisieren
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User updatedUser) { public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User updatedUser) {
return userRepository.findById(id).map(user -> { return userService.findById(id).map(user -> {
user.setUsername(updatedUser.getUsername()); String newName = updatedUser.getUsername() == null ? "" : updatedUser.getUsername().trim();
if (newName.isBlank()) {
throw new IllegalArgumentException("Ungültiger Benutzername.");
}
userService.assertUsernameAvailable(newName, id);
user.setUsername(newName);
if (updatedUser.getPassword() != null && !updatedUser.getPassword().isBlank()) { if (updatedUser.getPassword() != null && !updatedUser.getPassword().isBlank()) {
user.setPassword(passwordEncoder.encode(updatedUser.getPassword())); user.setPassword(passwordEncoder.encode(updatedUser.getPassword()));
} }
user.setEmail(updatedUser.getEmail()); user.setEmail(updatedUser.getEmail());
user.setRole(updatedUser.getRole()); user.setRole(userService.validateRole(updatedUser.getRole()));
return ResponseEntity.ok(userRepository.save(user)); userService.save(user);
return ResponseEntity.ok(user);
}).orElseGet(() -> ResponseEntity.notFound().build()); }).orElseGet(() -> ResponseEntity.notFound().build());
} }
// 🗑️ User löschen // 🗑️ User löschen (inkl. zugehöriger Scores/Achievements)
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) { public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userRepository.existsById(id)) { if (userService.findById(id).isEmpty()) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
userService.deleteById(id);
return ResponseEntity.noContent().build();
}
} }
@@ -1,31 +0,0 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/users")
public class UserViewController {
@Autowired
private UserRepository userRepository;
// Formular anzeigen
@GetMapping("/form")
public String showForm(Model model) {
model.addAttribute("user", new User());
return "user-form";
}
// Benutzer speichern
@PostMapping("/save")
public String saveUser(@ModelAttribute User user) {
userRepository.save(user);
return "user-success"; // statt redirect
}
}
@@ -6,6 +6,15 @@ public class ProgressData {
private int xpForNextLevel; private int xpForNextLevel;
private int userLevel; private int userLevel;
private int progressPercent; private int progressPercent;
private int streakDays;
public int getStreakDays() {
return streakDays;
}
public void setStreakDays(int streakDays) {
this.streakDays = streakDays;
}
public int getCorrectAnswers() { public int getCorrectAnswers() {
return correctAnswers; return correctAnswers;
@@ -1,7 +0,0 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AdminUserRepository extends JpaRepository<User, Long> {
}
@@ -2,7 +2,10 @@ package com.example.mindflux.repository;
import com.example.mindflux.model.GameScore; import com.example.mindflux.model.GameScore;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface GameScoreRepository extends JpaRepository<GameScore, Long> { public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
@@ -18,4 +21,24 @@ public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
/** Alle Spielstände eines Spielers löschen (z. B. beim Löschen des Kontos). */ /** Alle Spielstände eines Spielers löschen (z. B. beim Löschen des Kontos). */
void deleteByUsername(String username); void deleteByUsername(String username);
/** Gesamtsumme aller Scores eines Spielers (XP-Basis). */
@Query("select coalesce(sum(g.score), 0) from GameScore g where g.username = :username")
long sumScoreByUsername(@Param("username") String username);
/** Summe der moves-Werte (je nach Spiel gelöste Aufgaben/richtige Antworten). */
@Query("select coalesce(sum(g.moves), 0) from GameScore g where g.username = :username")
long sumMovesByUsername(@Param("username") String username);
/** Gesamtzahl gespielter Runden über alle Spiele. */
long countByUsername(String username);
/** Anzahl verschiedener Spiele, die der Spieler gespielt hat. */
@Query("select count(distinct g.game) from GameScore g where g.username = :username")
long countDistinctGamesByUsername(@Param("username") String username);
/** Alle Spieltage (distinct, absteigend) – Basis für die Streak-Berechnung. */
@Query(value = "select distinct cast(played_at as date) from game_scores where username = :username order by 1 desc",
nativeQuery = true)
List<java.sql.Date> findDistinctPlayDaysDesc(@Param("username") String username);
} }
@@ -2,6 +2,7 @@ package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularCategory; import com.example.mindflux.model.TechtacularCategory;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List; import java.util.List;
@@ -12,4 +13,8 @@ public interface TechtacularCategoryRepository extends JpaRepository<Techtacular
List<TechtacularCategory> findAllByOrderBySortOrderAscIdAsc(); List<TechtacularCategory> findAllByOrderBySortOrderAscIdAsc();
boolean existsBySlug(String slug); boolean existsBySlug(String slug);
/** Aktive Kategorien inkl. Fragen in einem Rutsch (vermeidet N+1 beim Board-Aufbau). */
@Query("select distinct c from TechtacularCategory c join fetch c.questions where c.active = true")
List<TechtacularCategory> findActiveWithQuestions();
} }
@@ -2,8 +2,22 @@ package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularQuestion; import com.example.mindflux.model.TechtacularQuestion;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface TechtacularQuestionRepository extends JpaRepository<TechtacularQuestion, Long> { public interface TechtacularQuestionRepository extends JpaRepository<TechtacularQuestion, Long> {
long countByCategoryId(Long categoryId); long countByCategoryId(Long categoryId);
/**
* Initialisiert die Antwort-Collections aller Fragen aktiver Kategorien im selben
* Persistence Context (zweite Query des 2-Query-Musters gegen N+1 beim Board-Aufbau).
*/
@Query("select distinct q from TechtacularQuestion q join fetch q.answers where q.category.active = true")
List<TechtacularQuestion> findActiveWithAnswers();
/** Fragenanzahl je Kategorie als eine GROUP-BY-Query statt eines Counts pro Zeile. */
@Query("select q.category.id, count(q) from TechtacularQuestion q group by q.category.id")
List<Object[]> countGroupedByCategory();
} }
@@ -5,5 +5,13 @@ import com.example.mindflux.model.Achievement;
import java.util.List; import java.util.List;
public interface AchievementService { public interface AchievementService {
/** Kompletter Katalog: verdiente Achievements mit Datum, restliche als gesperrt. */
List<Achievement> getAchievementsForUser(String username); List<Achievement> getAchievementsForUser(String username);
/**
* Prüft nach einem gespeicherten Spielergebnis alle Regeln und vergibt neue
* Achievements idempotent. Wird aus GameScoreService.recordScore() aufgerufen.
*/
void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame);
} }
@@ -2,19 +2,82 @@ package com.example.mindflux.service;
import com.example.mindflux.model.Achievement; import com.example.mindflux.model.Achievement;
import com.example.mindflux.repository.AchievementRepository; import com.example.mindflux.repository.AchievementRepository;
import org.springframework.beans.factory.annotation.Autowired; import com.example.mindflux.repository.GameScoreRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service @Service
public class AchievementServiceImpl implements AchievementService { public class AchievementServiceImpl implements AchievementService {
@Autowired /** Katalog aller Achievements (Titel -> Beschreibung), in Anzeige-Reihenfolge. */
private AchievementRepository achievementRepository; static final Map<String, String> CATALOG = new LinkedHashMap<>();
static {
CATALOG.put("Erster Sieg", "Schließe dein erstes Spiel ab.");
CATALOG.put("1000er-Club", "Erziele 1000 Punkte in einer einzigen Runde.");
CATALOG.put("Highscore-Jäger", "Verbessere deinen eigenen Highscore.");
CATALOG.put("Dauerbrenner", "Spiele dasselbe Spiel zehnmal.");
CATALOG.put("Punktesammler", "Sammle insgesamt 5000 Punkte.");
CATALOG.put("Allrounder", "Spiele vier verschiedene Spiele.");
}
private final AchievementRepository achievementRepository;
private final GameScoreRepository gameScoreRepository;
public AchievementServiceImpl(AchievementRepository achievementRepository,
GameScoreRepository gameScoreRepository) {
this.achievementRepository = achievementRepository;
this.gameScoreRepository = gameScoreRepository;
}
@Override @Override
public List<Achievement> getAchievementsForUser(String username) { public List<Achievement> getAchievementsForUser(String username) {
return achievementRepository.findByUsername(username); List<Achievement> earned = achievementRepository.findByUsername(username);
Map<String, Achievement> earnedByTitle = earned.stream()
.collect(Collectors.toMap(Achievement::getTitle, a -> a, (a, b) -> a));
List<Achievement> result = new ArrayList<>();
// Katalog in fester Reihenfolge: verdient mit Datum, sonst als gesperrte Karte.
CATALOG.forEach((title, description) -> {
Achievement achievement = earnedByTitle.remove(title);
result.add(achievement != null ? achievement
: new Achievement(title, description, null, false));
});
// Ältere/zusätzliche verdiente Achievements außerhalb des Katalogs behalten.
result.addAll(earnedByTitle.values());
return result;
}
@Override
@Transactional
public void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame) {
Set<String> existing = achievementRepository.findByUsername(username).stream()
.map(Achievement::getTitle)
.collect(Collectors.toSet());
award(existing, username, "Erster Sieg",
gameScoreRepository.countByUsername(username) >= 1);
award(existing, username, "1000er-Club", score >= 1000);
award(existing, username, "Highscore-Jäger", newHighscore && playsInGame > 1);
award(existing, username, "Dauerbrenner", playsInGame >= 10);
award(existing, username, "Punktesammler",
gameScoreRepository.sumScoreByUsername(username) >= 5000);
award(existing, username, "Allrounder",
gameScoreRepository.countDistinctGamesByUsername(username) >= 4);
}
private void award(Set<String> existing, String username, String title, boolean condition) {
if (!condition || existing.contains(title)) {
return;
}
Achievement achievement = new Achievement(title, CATALOG.get(title), null, true);
achievement.setUsername(username);
achievementRepository.save(achievement);
} }
} }
@@ -1,32 +0,0 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.AdminUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class AdminUserService {
@Autowired
private AdminUserRepository userRepository;
public List<User> findAll() {
return userRepository.findAll();
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public User save(User user) {
return userRepository.save(user);
}
public void deleteById(Long id) {
userRepository.deleteById(id);
}
}
@@ -5,6 +5,8 @@ import com.example.mindflux.dto.ScoreResult;
import com.example.mindflux.dto.ScoreSubmission; import com.example.mindflux.dto.ScoreSubmission;
import com.example.mindflux.model.GameScore; import com.example.mindflux.model.GameScore;
import com.example.mindflux.repository.GameScoreRepository; import com.example.mindflux.repository.GameScoreRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -16,10 +18,14 @@ import java.time.LocalDateTime;
@Service @Service
public class GameScoreService { public class GameScoreService {
private final GameScoreRepository repository; private static final Logger log = LoggerFactory.getLogger(GameScoreService.class);
public GameScoreService(GameScoreRepository repository) { private final GameScoreRepository repository;
private final AchievementService achievementService;
public GameScoreService(GameScoreRepository repository, AchievementService achievementService) {
this.repository = repository; this.repository = repository;
this.achievementService = achievementService;
} }
/** /**
@@ -66,9 +72,33 @@ public class GameScoreService {
int highscore = previousBest == null ? score : Math.max(previousBest.getScore(), score); int highscore = previousBest == null ? score : Math.max(previousBest.getScore(), score);
long plays = repository.countByUsernameAndGame(username, game); long plays = repository.countByUsernameAndGame(username, game);
// Achievement-Prüfung darf das Speichern des Scores nie gefährden.
try {
achievementService.checkAndAward(username, game, score, newHighscore, plays);
} catch (Exception e) {
log.error("Achievement-Prüfung fehlgeschlagen für {} / {}", username, game, e);
}
return new ScoreResult(score, highscore, newHighscore, plays); return new ScoreResult(score, highscore, newHighscore, plays);
} }
/** Summe aller Scores eines Spielers über alle Spiele (XP-Basis). */
public long totalScore(String username) {
return repository.sumScoreByUsername(username);
}
/** Summe der moves-Werte (je nach Spiel gelöste Aufgaben/richtige Antworten). */
public long totalMoves(String username) {
return repository.sumMovesByUsername(username);
}
/** Alle Spieltage absteigend sortiert (für die Tages-Streak). */
public java.util.List<java.time.LocalDate> playDaysDesc(String username) {
return repository.findDistinctPlayDaysDesc(username).stream()
.map(java.sql.Date::toLocalDate)
.toList();
}
/** Aktuelle Statistik (Highscore, Anzahl Spiele, zuletzt gespielt) für ein Spiel. */ /** Aktuelle Statistik (Highscore, Anzahl Spiele, zuletzt gespielt) für ein Spiel. */
public GameStats getStats(String username, String game) { public GameStats getStats(String username, String game) {
int highscore = repository.findTopByUsernameAndGameOrderByScoreDesc(username, game) int highscore = repository.findTopByUsernameAndGameOrderByScoreDesc(username, game)
@@ -0,0 +1,55 @@
package com.example.mindflux.service;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Einfacher In-Memory-Schutz gegen Brute-Force auf den Login: Nach zu vielen
* Fehlversuchen wird der Benutzername für ein Zeitfenster gesperrt. Erfolgreicher
* Login setzt den Zähler zurück. Bewusst ohne externe Abhängigkeiten gehalten.
*/
@Service
public class LoginAttemptService {
static final int MAX_ATTEMPTS = 5;
static final Duration WINDOW = Duration.ofMinutes(15);
private record Attempt(int count, Instant firstFailure) {}
private final Map<String, Attempt> attempts = new ConcurrentHashMap<>();
public void loginFailed(String username) {
String key = normalize(username);
attempts.compute(key, (k, existing) -> {
Instant now = Instant.now();
if (existing == null || existing.firstFailure().plus(WINDOW).isBefore(now)) {
return new Attempt(1, now);
}
return new Attempt(existing.count() + 1, existing.firstFailure());
});
}
public void loginSucceeded(String username) {
attempts.remove(normalize(username));
}
public boolean isBlocked(String username) {
Attempt attempt = attempts.get(normalize(username));
if (attempt == null) {
return false;
}
if (attempt.firstFailure().plus(WINDOW).isBefore(Instant.now())) {
attempts.remove(normalize(username));
return false;
}
return attempt.count() >= MAX_ATTEMPTS;
}
private String normalize(String username) {
return username == null ? "" : username.trim().toLowerCase();
}
}
@@ -6,6 +6,7 @@ import com.example.mindflux.dto.ProgressData;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -19,11 +20,12 @@ public class ProgressService {
static { static {
GAMES.put("mindmatch", "MindMatch"); GAMES.put("mindmatch", "MindMatch");
GAMES.put("techtacular", "TechTacular"); GAMES.put("techtacular", "TechTacular");
GAMES.put("syntax-saga", "Syntax Saga"); GAMES.put("devlab", "DevLab");
GAMES.put("escape-room", "Code Escape Room"); GAMES.put("syntax-saga", "SyntaxSaga");
GAMES.put("service-clash", "Service Clash"); GAMES.put("codeescape", "CodeEscape");
GAMES.put("bug-hunter", "Bug Hunter"); GAMES.put("serviceclash", "ServiceClash");
GAMES.put("netzwerker", "Netzwerker"); GAMES.put("bughunter", "BugHunter");
GAMES.put("networker", "NetWorker");
} }
private final GameScoreService gameScoreService; private final GameScoreService gameScoreService;
@@ -32,17 +34,53 @@ public class ProgressService {
this.gameScoreService = gameScoreService; this.gameScoreService = gameScoreService;
} }
/** XP pro Level: Level n ist erreicht ab (n-1) * 1000 Gesamt-XP. */
static final int XP_PER_LEVEL = 1000;
/** Echter Gesamtfortschritt aus game_scores: XP = Summe aller Scores, Level linear. */
public ProgressData getOverallProgress(String username) { public ProgressData getOverallProgress(String username) {
// Dummy-Daten – später aus DB berechnen long totalXp = gameScoreService.totalScore(username);
int level = (int) (totalXp / XP_PER_LEVEL) + 1;
long nextLevelAt = (long) level * XP_PER_LEVEL;
int withinLevel = (int) (totalXp - (long) (level - 1) * XP_PER_LEVEL);
ProgressData data = new ProgressData(); ProgressData data = new ProgressData();
data.setCorrectAnswers(432); // Näherung: moves = je nach Spiel gelöste Aufgaben bzw. richtige Antworten
data.setCurrentXp(2400); // (bei MindMatch sind es Zug-Versuche – bewusst in Kauf genommen).
data.setXpForNextLevel(3000); data.setCorrectAnswers((int) Math.min(gameScoreService.totalMoves(username), Integer.MAX_VALUE));
data.setUserLevel(5); data.setCurrentXp((int) Math.min(totalXp, Integer.MAX_VALUE));
data.setProgressPercent(78); data.setXpForNextLevel((int) Math.min(nextLevelAt, Integer.MAX_VALUE));
data.setUserLevel(level);
data.setProgressPercent(Math.round(withinLevel * 100f / XP_PER_LEVEL));
data.setStreakDays(computeStreak(gameScoreService.playDaysDesc(username), LocalDate.now()));
return data; return data;
} }
/**
* Tages-Streak: Anzahl zusammenhängender Spieltage bis heute. Ein Tag Karenz –
* wer heute noch nicht gespielt hat, behält die Serie von gestern.
*/
static int computeStreak(List<LocalDate> playDaysDesc, LocalDate today) {
if (playDaysDesc.isEmpty()) {
return 0;
}
LocalDate anchor = playDaysDesc.get(0);
if (anchor.isBefore(today.minusDays(1))) {
return 0;
}
int streak = 0;
LocalDate expected = anchor;
for (LocalDate day : playDaysDesc) {
if (day.equals(expected)) {
streak++;
expected = expected.minusDays(1);
} else {
break;
}
}
return streak;
}
/** Echter Spiel-Fortschritt pro Spiel aus der Datenbank (game_scores). */ /** Echter Spiel-Fortschritt pro Spiel aus der Datenbank (game_scores). */
public List<GameProgress> getGameProgress(String username) { public List<GameProgress> getGameProgress(String username) {
List<GameProgress> result = new ArrayList<>(); List<GameProgress> result = new ArrayList<>();
@@ -0,0 +1,11 @@
package com.example.mindflux.service;
/** Ergebnis einer Selbstregistrierung – wird vom RegisterController auf Redirect-Flags gemappt. */
public enum RegistrationResult {
SUCCESS,
USERNAME_INVALID,
USERNAME_TAKEN,
EMAIL_INVALID,
PASSWORD_MISMATCH,
PASSWORD_TOO_SHORT
}
@@ -43,8 +43,10 @@ public class TechtacularService {
public TechtacularBoardView buildBoard(Set<Long> exclude) { public TechtacularBoardView buildBoard(Set<Long> exclude) {
Set<Long> excluded = (exclude == null) ? Set.of() : exclude; Set<Long> excluded = (exclude == null) ? Set.of() : exclude;
List<TechtacularCategory> usable = categoryRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc() // 2-Query-Muster statt N+1: Kategorien+Fragen in einem Fetch-Join, danach alle
.stream().filter(c -> !c.getQuestions().isEmpty()).collect(Collectors.toList()); // Antwort-Collections in einer zweiten Query im selben Persistence Context laden.
List<TechtacularCategory> usable = new ArrayList<>(categoryRepository.findActiveWithQuestions());
questionRepository.findActiveWithAnswers();
Collections.shuffle(usable, random); Collections.shuffle(usable, random);
List<TechtacularCategory> chosen = usable.stream().limit(BOARD_CATEGORIES).toList(); List<TechtacularCategory> chosen = usable.stream().limit(BOARD_CATEGORIES).toList();
@@ -128,12 +130,14 @@ public class TechtacularService {
// ---------- Admin ---------- // ---------- Admin ----------
/** Leichte Übersicht: Kategorien mit Fragenanzahl (lädt nicht alle Fragen). */ /** Leichte Übersicht: Kategorien mit Fragenanzahl (eine GROUP-BY-Query für alle Counts). */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<AdminCategoryRow> categoryRows() { public List<AdminCategoryRow> categoryRows() {
Map<Long, Long> counts = questionRepository.countGroupedByCategory().stream()
.collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));
return categoryRepository.findAllByOrderBySortOrderAscIdAsc().stream() return categoryRepository.findAllByOrderBySortOrderAscIdAsc().stream()
.map(c -> new AdminCategoryRow(c.getId(), c.getSlug(), c.getTitle(), c.isActive(), .map(c -> new AdminCategoryRow(c.getId(), c.getSlug(), c.getTitle(), c.isActive(),
questionRepository.countByCategoryId(c.getId()))) counts.getOrDefault(c.getId(), 0L)))
.toList(); .toList();
} }
@@ -2,9 +2,34 @@ package com.example.mindflux.service;
import com.example.mindflux.model.User; import com.example.mindflux.model.User;
import java.util.List;
import java.util.Optional;
public interface UserService { public interface UserService {
User findByUsername(String username); User findByUsername(String username);
User findByUsernameIgnoreCase(String username); User findByUsernameIgnoreCase(String username);
List<User> findAll();
Optional<User> findById(Long id);
void save(User user); void save(User user);
void deleteByUsername(String username); // <-- Nur die Methodensignatur, ohne {}
/** Selbstregistrierung mit allen Regeln (Passwortlänge, E-Mail-Format, Eindeutigkeit). */
RegistrationResult register(String username, String email, String password, String confirmPassword);
/** Anlage durch Admin/REST – wirft IllegalArgumentException bei Regelverstößen. */
User createUser(String username, String email, String rawPassword, String role);
/** Normalisiert die Rolle (Default USER) und wirft bei unbekannten Rollen. */
String validateRole(String role);
/** Wirft, wenn der Benutzername (case-insensitiv) bereits einem anderen Nutzer gehört. */
void assertUsernameAvailable(String username, Long exceptId);
void deleteByUsername(String username);
void deleteById(Long id);
} }
@@ -4,25 +4,46 @@ import com.example.mindflux.model.User;
import com.example.mindflux.repository.AchievementRepository; import com.example.mindflux.repository.AchievementRepository;
import com.example.mindflux.repository.GameScoreRepository; import com.example.mindflux.repository.GameScoreRepository;
import com.example.mindflux.repository.UserRepository; import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Zentrale Stelle für alle Benutzer-Operationen: Registrierung, Admin-/REST-Anlage,
* Validierung, Passwort-Encoding und Löschen. Alle Anlage-Pfade laufen hier durch,
* damit die Regeln (BCrypt, Rollen, Eindeutigkeit) nicht auseinanderdriften.
*/
@Service @Service
public class UserServiceImpl implements UserService { public class UserServiceImpl implements UserService {
@Autowired private static final int MIN_PASSWORD_LENGTH = 8;
private UserRepository userRepository; private static final Set<String> KNOWN_ROLES = Set.of("USER", "ADMIN");
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
@Autowired private final UserRepository userRepository;
private GameScoreRepository gameScoreRepository; private final GameScoreRepository gameScoreRepository;
private final AchievementRepository achievementRepository;
private final BCryptPasswordEncoder passwordEncoder;
@Autowired public UserServiceImpl(UserRepository userRepository,
private AchievementRepository achievementRepository; GameScoreRepository gameScoreRepository,
AchievementRepository achievementRepository,
BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.gameScoreRepository = gameScoreRepository;
this.achievementRepository = achievementRepository;
this.passwordEncoder = passwordEncoder;
}
@Override @Override
public User findByUsername(String username) { public User findByUsername(String username) {
return userRepository.findByUsername(username).orElse(null); // <-- Optional aufgelöst return userRepository.findByUsername(username).orElse(null);
} }
@Override @Override
@@ -30,19 +51,100 @@ public class UserServiceImpl implements UserService {
return userRepository.findByUsernameIgnoreCase(username).orElse(null); return userRepository.findByUsernameIgnoreCase(username).orElse(null);
} }
@Override
public List<User> findAll() {
return userRepository.findAll();
}
@Override
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
@Override @Override
public void save(User user) { public void save(User user) {
userRepository.save(user); userRepository.save(user);
} }
@Override
public RegistrationResult register(String username, String email, String password, String confirmPassword) {
String name = username == null ? "" : username.trim();
if (name.isBlank() || name.length() > 100) {
return RegistrationResult.USERNAME_INVALID;
}
if (password == null || password.length() < MIN_PASSWORD_LENGTH) {
return RegistrationResult.PASSWORD_TOO_SHORT;
}
if (!password.equals(confirmPassword)) {
return RegistrationResult.PASSWORD_MISMATCH;
}
if (email == null || !EMAIL.matcher(email.trim()).matches()) {
return RegistrationResult.EMAIL_INVALID;
}
if (userRepository.findByUsernameIgnoreCase(name).isPresent()) {
return RegistrationResult.USERNAME_TAKEN;
}
User user = new User(name, passwordEncoder.encode(password), email.trim(), "USER");
try {
userRepository.save(user);
} catch (DataIntegrityViolationException e) {
// Race bei parallelem Insert – der case-insensitive Unique-Index (V9) greift.
return RegistrationResult.USERNAME_TAKEN;
}
return RegistrationResult.SUCCESS;
}
@Override
public User createUser(String username, String email, String rawPassword, String role) {
String name = username == null ? "" : username.trim();
if (name.isBlank() || name.length() > 100) {
throw new IllegalArgumentException("Ungültiger Benutzername.");
}
if (rawPassword == null || rawPassword.length() < MIN_PASSWORD_LENGTH) {
throw new IllegalArgumentException(
"Das Passwort muss mindestens " + MIN_PASSWORD_LENGTH + " Zeichen lang sein.");
}
String checkedRole = validateRole(role);
assertUsernameAvailable(name, null);
User user = new User(name, passwordEncoder.encode(rawPassword),
email == null ? null : email.trim(), checkedRole);
try {
return userRepository.save(user);
} catch (DataIntegrityViolationException e) {
throw new IllegalArgumentException("Benutzername bereits vergeben.");
}
}
@Override
public String validateRole(String role) {
String normalized = (role == null || role.isBlank()) ? "USER" : role.trim().toUpperCase();
if (!KNOWN_ROLES.contains(normalized)) {
throw new IllegalArgumentException("Unbekannte Rolle: " + role);
}
return normalized;
}
@Override
public void assertUsernameAvailable(String username, Long exceptId) {
userRepository.findByUsernameIgnoreCase(username).ifPresent(existing -> {
if (exceptId == null || !existing.getId().equals(exceptId)) {
throw new IllegalArgumentException("Benutzername bereits vergeben.");
}
});
}
@Override @Override
@Transactional @Transactional
public void deleteByUsername(String username) { public void deleteByUsername(String username) {
// Zugehörige Daten mitlöschen (kein FK/Cascade -> manuell), damit keine // Defensive Mitlöschung – seit V10 sichern zusätzlich FK-Kaskaden in der DB ab.
// verwaisten Datensätze zurückbleiben und ein neuer gleichnamiger Account
// keine Alt-Daten erbt.
gameScoreRepository.deleteByUsername(username); gameScoreRepository.deleteByUsername(username);
achievementRepository.deleteByUsername(username); achievementRepository.deleteByUsername(username);
userRepository.deleteByUsername(username); userRepository.deleteByUsername(username);
} }
@Override
@Transactional
public void deleteById(Long id) {
userRepository.findById(id).ifPresent(user -> deleteByUsername(user.getUsername()));
}
} }
@@ -0,0 +1,30 @@
-- Datenintegrität: E-Mail eindeutig (case-insensitiv) und referenzielle Integrität
-- für Scores/Achievements. Verwaiste Altdaten werden vorher bereinigt.
-- 1) E-Mail-Dubletten auflösen: Die älteste Zeile behält die Adresse, spätere verlieren sie.
UPDATE users u
SET email = NULL
WHERE u.email IS NOT NULL
AND EXISTS (
SELECT 1 FROM users o
WHERE o.email IS NOT NULL
AND lower(o.email) = lower(u.email)
AND o.id < u.id
);
CREATE UNIQUE INDEX uq_users_email_lower ON users (lower(email)) WHERE email IS NOT NULL;
-- 2) Verwaiste Spielstände/Achievements entfernen (stammen aus Zeiten ohne FK).
DELETE FROM game_scores WHERE username NOT IN (SELECT username FROM users);
DELETE FROM achievements WHERE username IS NOT NULL AND username NOT IN (SELECT username FROM users);
-- 3) Referenzielle Integrität: Löschen räumt Daten mit ab, Umbenennen zieht sie mit um.
ALTER TABLE game_scores
ADD CONSTRAINT fk_game_scores_user
FOREIGN KEY (username) REFERENCES users (username)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE achievements
ADD CONSTRAINT fk_achievements_user
FOREIGN KEY (username) REFERENCES users (username)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- Benutzernamen case-insensitiv eindeutig machen: Die App prüft bei Login und
-- Registrierung ignoreCase, die DB kannte bisher nur ein case-sensitives UNIQUE.
-- Damit ist auch das Check-then-Insert-Race der Registrierung abgesichert.
-- Voraussetzung: Es existieren keine Bestandsnamen, die sich nur in der
-- Groß-/Kleinschreibung unterscheiden (sonst vorher manuell bereinigen).
CREATE UNIQUE INDEX uq_users_username_lower ON users (lower(username));
+6
View File
@@ -203,3 +203,9 @@ body.mf-nav-open .mf-nav-backdrop { opacity:1; pointer-events:auto; }
} }
@media (max-width:560px){ .mf-grid{grid-template-columns:1fr;} } @media (max-width:560px){ .mf-grid{grid-template-columns:1fr;} }
/* ===== Profilbereich: Tab-Navigation (Fragment fragments/layout :: profileTabs) ===== */
.mf-tab-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-bottom:1.4rem; }
.mf-tab { padding:.55rem .95rem; border-radius:10px; border:1px solid var(--border); color:var(--muted); text-decoration:none; font-size:.85rem; font-weight:700; transition:all .18s ease; }
.mf-tab:hover { color:var(--text); background:var(--surface-2); }
.mf-tab.active { color:var(--accent-ink); background:linear-gradient(100deg,var(--accent),var(--accent-2)); box-shadow:0 0 18px var(--glow-s); border-color:transparent; }
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Entwurfsmuster - FinalFlux AP2</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/theme.css">
<link rel="stylesheet" href="/css/finalflux.css">
<script src="/js/theme.js"></script>
</head>
<body class="finalflux-page">
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<main>
<a href="/finalflux" class="back-link">Zurück zu FinalFlux</a>
<h1>Entwurfsmuster (Design Patterns)</h1>
<section>
<h2>Lernfokus</h2>
<p>AP2 Anwendungsentwicklung: die prüfungsrelevanten Entwurfsmuster erkennen, erklären und dem richtigen Problem zuordnen.</p>
<ul class="key-list">
<li>Zweck von Entwurfsmustern erklären.</li>
<li>Singleton, Factory und Observer unterscheiden.</li>
<li>MVC-Schichten und ihre Aufgaben zuordnen.</li>
</ul>
</section>
<section>
<h2>Was sind Entwurfsmuster?</h2>
<p>Entwurfsmuster sind bewährte, wiederverwendbare Lösungsschablonen für häufige Probleme im Software-Design. Sie sind kein fertiger Code, sondern beschreiben eine Struktur aus Klassen und deren Zusammenspiel. In der AP2 wird typischerweise verlangt, ein Muster zu benennen, seinen Zweck zu erklären oder es in einem UML-Diagramm wiederzuerkennen.</p>
<p class="merksatz"><strong>Merksatz:</strong> Ein Entwurfsmuster ist eine Schablone für ein Entwurfsproblem – nicht der Code selbst.</p>
</section>
<section>
<h2>Singleton</h2>
<p>Stellt sicher, dass von einer Klasse <strong>genau eine Instanz</strong> existiert, und bietet einen globalen Zugriffspunkt darauf. Typische Einsätze: Konfiguration, Logger, Verbindungs-Pools.</p>
<ul class="key-list">
<li><strong>Privater Konstruktor:</strong> verhindert Instanziierung von außen.</li>
<li><strong>Statische Zugriffsmethode</strong> (z. B. <code>getInstance()</code>) liefert immer dieselbe Instanz.</li>
<li><strong>Kritik:</strong> globaler Zustand erschwert Tests – in der Prüfung als Nachteil nennen können.</li>
</ul>
</section>
<section>
<h2>Factory (Fabrikmethode)</h2>
<p>Kapselt die Objekterzeugung: Der aufrufende Code kennt nur das Interface, die Fabrik entscheidet, welche konkrete Klasse instanziiert wird. So lassen sich neue Varianten ergänzen, ohne den Client-Code zu ändern (Offen-Geschlossen-Prinzip).</p>
<ul class="key-list">
<li><strong>Problem:</strong> <code>new KonkreteKlasse()</code> überall verstreut macht Änderungen teuer.</li>
<li><strong>Lösung:</strong> zentrale Erzeugungsmethode, die das passende Objekt liefert.</li>
</ul>
</section>
<section>
<h2>Observer (Beobachter)</h2>
<p>Ein Subjekt hält eine Liste von Beobachtern und benachrichtigt sie automatisch bei Zustandsänderungen (1:n-Abhängigkeit). Grundlage vieler GUI- und Event-Systeme.</p>
<ul class="key-list">
<li><strong>Subjekt:</strong> <code>anmelden()</code>, <code>abmelden()</code>, <code>benachrichtigen()</code>.</li>
<li><strong>Beobachter:</strong> gemeinsames Interface mit <code>aktualisiere()</code>.</li>
<li><strong>Vorteil:</strong> lose Kopplung – das Subjekt kennt nur das Interface.</li>
</ul>
<p class="merksatz"><strong>Merksatz:</strong> Observer = abonnieren und benachrichtigt werden, statt ständig nachzufragen (Polling).</p>
</section>
<section>
<h2>MVC (Model-View-Controller)</h2>
<p>Architektur-Muster, das eine Anwendung in drei Verantwortlichkeiten trennt. In der AP2 häufig als Zuordnungsaufgabe gefragt.</p>
<ul class="key-list">
<li><strong>Model:</strong> Daten und Geschäftslogik – unabhängig von der Darstellung.</li>
<li><strong>View:</strong> Präsentation der Daten für den Benutzer.</li>
<li><strong>Controller:</strong> nimmt Eingaben entgegen, ruft das Model auf, wählt die View.</li>
</ul>
<p>Vorteile: getrennte Zuständigkeiten, parallele Entwicklung, bessere Testbarkeit, austauschbare Oberflächen.</p>
</section>
<section>
<h2>Prüfungsmodus</h2>
<p>Dieser FluxCheck führt dich durch die wichtigsten AP2-Aspekte dieses Themas. Du kannst Antworten prüfen, Fragen überspringen und bekommst am Ende eine Auswertung nach Kategorie.</p>
</section>
<section class="finalflux-test" data-finalflux-test aria-live="polite">
<div class="test-meta">
<span data-question-counter>Frage 1</span>
<span data-score-counter>0 richtig</span>
</div>
<div class="category-block">
<p class="eyebrow">Kategorie</p>
<h2 data-category-title>Entwurfsmuster</h2>
<p data-category-hint>AP2-Training</p>
</div>
<div class="question-card">
<h3 data-question-text></h3>
<div data-answer-options class="answer-options"></div>
<div data-answer-feedback class="fluxcheck-result"></div>
</div>
<div class="test-actions">
<button type="button" data-check-answer>Antwort prüfen</button>
<button type="button" data-skip-question class="secondary">Überspringen</button>
<button type="button" data-next-question class="secondary" disabled>Weiter</button>
</div>
</section>
<section data-test-result class="finalflux-panel" hidden>
<h2>Auswertung</h2>
<p data-result-summary></p>
<ul data-category-summary class="key-list"></ul>
<div class="finalflux-actions">
<button type="button" data-restart-test>Test neu starten</button>
<a href="/finalflux">Zurück zur Übersicht</a>
</div>
</section>
</main>
<script src="/js/finalflux-test.js" data-questions-src="/finalflux/questions/ap2_fiae_entwurfsmuster.json" defer></script>
</body>
</html>
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Hochverfügbarkeit - FinalFlux AP2</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/theme.css">
<link rel="stylesheet" href="/css/finalflux.css">
<script src="/js/theme.js"></script>
</head>
<body class="finalflux-page">
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<main>
<a href="/finalflux" class="back-link">Zurück zu FinalFlux</a>
<h1>Hochverfügbarkeit (HA)</h1>
<section>
<h2>Lernfokus</h2>
<p>AP2 Systemintegration: Verfügbarkeit berechnen, Redundanzkonzepte unterscheiden und RTO/RPO sicher einordnen.</p>
<ul class="key-list">
<li>Verfügbarkeit in Prozent und Ausfallzeit umrechnen.</li>
<li>Redundanzkonzepte (RAID, Cluster, USV, Netzteil) einordnen.</li>
<li>RTO und RPO unterscheiden.</li>
</ul>
</section>
<section>
<h2>Was bedeutet Hochverfügbarkeit?</h2>
<p>Hochverfügbarkeit beschreibt die Fähigkeit eines Systems, trotz Ausfällen einzelner Komponenten weiterzulaufen. Die Verfügbarkeit wird in Prozent der Betriebszeit angegeben – die bekannten „Neunen": 99 % bedeuten rund 3,65 Tage Ausfall pro Jahr, 99,9 % rund 8,76 Stunden, 99,99 % rund 53 Minuten.</p>
<ul class="key-list">
<li><strong>Formel:</strong> Verfügbarkeit = (Gesamtzeit − Ausfallzeit) / Gesamtzeit × 100 %.</li>
<li><strong>SPOF (Single Point of Failure):</strong> jede Komponente, deren Ausfall das Gesamtsystem stoppt – Ziel ist, SPOFs durch Redundanz zu eliminieren.</li>
<li><strong>MTBF/MTTR:</strong> mittlere Zeit zwischen Ausfällen bzw. mittlere Reparaturzeit.</li>
</ul>
<p class="merksatz"><strong>Merksatz:</strong> Jede zusätzliche „Neun" kostet überproportional mehr – Verfügbarkeit ist immer eine Kosten-Nutzen-Abwägung.</p>
</section>
<section>
<h2>Redundanzkonzepte</h2>
<p>Redundanz bedeutet, kritische Komponenten mehrfach auszulegen, damit der Ausfall einer Komponente abgefangen wird.</p>
<ul class="key-list">
<li><strong>RAID:</strong> schützt gegen Ausfall einzelner Festplatten – <em>ersetzt aber kein Backup</em> (gelöschte Daten sind auf allen Platten gelöscht).</li>
<li><strong>Cluster (Failover):</strong> zweiter Server übernimmt automatisch, wenn der aktive ausfällt (aktiv/passiv) oder Last wird verteilt (aktiv/aktiv).</li>
<li><strong>USV:</strong> überbrückt Stromausfälle und ermöglicht geordnetes Herunterfahren.</li>
<li><strong>Redundante Netzteile/Netzwerkpfade:</strong> doppelte Stromversorgung, zwei Switches/Uplinks gegen SPOFs im Netz.</li>
<li><strong>Georedundanz:</strong> zweiter Standort gegen Standortausfall (Brand, Hochwasser).</li>
</ul>
</section>
<section>
<h2>RTO und RPO</h2>
<p>Zwei Kennzahlen aus der Notfallplanung, die in der AP2 gern verwechselt werden:</p>
<ul class="key-list">
<li><strong>RTO (Recovery Time Objective):</strong> Wie lange darf die Wiederherstellung maximal dauern? → maximale Ausfall<em>zeit</em>.</li>
<li><strong>RPO (Recovery Point Objective):</strong> Wie viele Daten dürfen maximal verloren gehen? → maximaler Daten<em>verlust</em>, bestimmt die Backup-Frequenz.</li>
</ul>
<p class="merksatz"><strong>Merksatz:</strong> RTO = Zeit bis alles wieder läuft, RPO = Zeitraum an Daten, der fehlen darf.</p>
</section>
<section>
<h2>Prüfungsmodus</h2>
<p>Dieser FluxCheck führt dich durch die wichtigsten AP2-Aspekte dieses Themas. Du kannst Antworten prüfen, Fragen überspringen und bekommst am Ende eine Auswertung nach Kategorie.</p>
</section>
<section class="finalflux-test" data-finalflux-test aria-live="polite">
<div class="test-meta">
<span data-question-counter>Frage 1</span>
<span data-score-counter>0 richtig</span>
</div>
<div class="category-block">
<p class="eyebrow">Kategorie</p>
<h2 data-category-title>Hochverfügbarkeit</h2>
<p data-category-hint>AP2-Training</p>
</div>
<div class="question-card">
<h3 data-question-text></h3>
<div data-answer-options class="answer-options"></div>
<div data-answer-feedback class="fluxcheck-result"></div>
</div>
<div class="test-actions">
<button type="button" data-check-answer>Antwort prüfen</button>
<button type="button" data-skip-question class="secondary">Überspringen</button>
<button type="button" data-next-question class="secondary" disabled>Weiter</button>
</div>
</section>
<section data-test-result class="finalflux-panel" hidden>
<h2>Auswertung</h2>
<p data-result-summary></p>
<ul data-category-summary class="key-list"></ul>
<div class="finalflux-actions">
<button type="button" data-restart-test>Test neu starten</button>
<a href="/finalflux">Zurück zur Übersicht</a>
</div>
</section>
</main>
<script src="/js/finalflux-test.js" data-questions-src="/finalflux/questions/ap2_fisi_hochverfuegbarkeit.json" defer></script>
</body>
</html>
@@ -0,0 +1,77 @@
{
"title": "Entwurfsmuster",
"questions": [
{
"category": "Grundlagen",
"hint": "Zweck von Entwurfsmustern.",
"text": "Was ist ein Entwurfsmuster?",
"answers": [
"Eine bewährte, wiederverwendbare Lösungsschablone für ein häufiges Entwurfsproblem.",
"Eine fertige Code-Bibliothek, die direkt eingebunden wird.",
"Ein Werkzeug zur automatischen Codegenerierung aus UML-Diagrammen."
],
"correct": 0,
"explanation": "Ein Entwurfsmuster beschreibt eine Struktur und ihr Zusammenspiel – es ist kein fertiger Code, sondern eine Schablone."
},
{
"category": "Singleton",
"hint": "Genau eine Instanz.",
"text": "Welches Merkmal gehört zwingend zum Singleton-Muster?",
"answers": [
"Ein öffentlicher Konstruktor, damit jede Klasse Instanzen erzeugen kann.",
"Ein privater Konstruktor und eine statische Zugriffsmethode auf die einzige Instanz.",
"Eine Liste von Beobachtern, die bei Änderungen benachrichtigt werden."
],
"correct": 1,
"explanation": "Der private Konstruktor verhindert Fremd-Instanziierung; getInstance() liefert immer dieselbe Instanz."
},
{
"category": "Factory",
"hint": "Objekterzeugung kapseln.",
"text": "Welches Problem löst die Fabrikmethode (Factory)?",
"answers": [
"Sie sorgt dafür, dass Objekte automatisch gelöscht werden.",
"Sie verschlüsselt die Kommunikation zwischen Objekten.",
"Sie kapselt die Objekterzeugung, sodass der Client nur das Interface kennt."
],
"correct": 2,
"explanation": "Die Fabrik entscheidet, welche konkrete Klasse instanziiert wird – neue Varianten erfordern keine Änderung im Client-Code."
},
{
"category": "Observer",
"hint": "1:n-Abhängigkeit.",
"text": "Wann ist das Observer-Muster die richtige Wahl?",
"answers": [
"Wenn mehrere Objekte automatisch über Zustandsänderungen eines Objekts informiert werden sollen.",
"Wenn von einer Klasse nur eine einzige Instanz existieren darf.",
"Wenn die Erzeugung von Objekten zentralisiert werden soll."
],
"correct": 0,
"explanation": "Das Subjekt benachrichtigt alle angemeldeten Beobachter – lose gekoppelt über ein gemeinsames Interface."
},
{
"category": "MVC",
"hint": "Drei Verantwortlichkeiten.",
"text": "Welche Aufgabe hat der Controller im MVC-Muster?",
"answers": [
"Er speichert die Daten und enthält die Geschäftslogik.",
"Er nimmt Benutzereingaben entgegen, ruft das Model auf und wählt die View.",
"Er stellt die Daten grafisch für den Benutzer dar."
],
"correct": 1,
"explanation": "Model = Daten/Logik, View = Darstellung, Controller = Vermittler zwischen Eingabe, Model und View."
},
{
"category": "MVC",
"hint": "Vorteile der Trennung.",
"text": "Welcher Vorteil ergibt sich direkt aus der MVC-Trennung?",
"answers": [
"Die Anwendung benötigt keine Datenbank mehr.",
"Der Quellcode wird automatisch schneller ausgeführt.",
"Oberfläche und Logik sind getrennt test- und austauschbar."
],
"correct": 2,
"explanation": "Durch die Trennung der Schichten lassen sich Views austauschen und Model-Logik isoliert testen."
}
]
}
@@ -0,0 +1,77 @@
{
"title": "Hochverfügbarkeit",
"questions": [
{
"category": "Grundlagen",
"hint": "Die Neunen kennen.",
"text": "Ein System hat eine Verfügbarkeit von 99,9 % pro Jahr. Wie viel Ausfallzeit entspricht das ungefähr?",
"answers": [
"Rund 8,8 Stunden pro Jahr.",
"Rund 3,65 Tage pro Jahr.",
"Rund 53 Minuten pro Jahr."
],
"correct": 0,
"explanation": "0,1 % von 8760 Stunden sind rund 8,76 Stunden. 3,65 Tage gehören zu 99 %, 53 Minuten zu 99,99 %."
},
{
"category": "Grundlagen",
"hint": "Kritische Einzelkomponenten.",
"text": "Was ist ein Single Point of Failure (SPOF)?",
"answers": [
"Eine Komponente, die doppelt ausgelegt ist und darum nie ausfällt.",
"Eine Komponente, deren Ausfall das gesamte System zum Stillstand bringt.",
"Der zentrale Monitoring-Server eines Rechenzentrums."
],
"correct": 1,
"explanation": "Ein SPOF ist jede nicht redundante Komponente, an der das Gesamtsystem hängt – Ziel ist, SPOFs durch Redundanz zu eliminieren."
},
{
"category": "Redundanz",
"hint": "RAID richtig einordnen.",
"text": "Warum ersetzt ein RAID kein Backup?",
"answers": [
"Weil RAID nur bei SSDs funktioniert, Backups aber auf Festplatten liegen.",
"Weil RAID die Daten komprimiert und dadurch Datenverlust entsteht.",
"Weil versehentlich gelöschte oder verschlüsselte Daten sofort auf allen Platten betroffen sind."
],
"correct": 2,
"explanation": "RAID schützt gegen Hardware-Ausfall einzelner Platten. Logische Fehler (Löschen, Ransomware) treffen alle Kopien gleichzeitig – dagegen hilft nur ein Backup."
},
{
"category": "Redundanz",
"hint": "Cluster-Betriebsarten.",
"text": "Was kennzeichnet einen Aktiv/Passiv-Failover-Cluster?",
"answers": [
"Ein Standby-Server übernimmt automatisch, wenn der aktive Server ausfällt.",
"Beide Server bearbeiten dauerhaft gemeinsam die Last.",
"Die Server sichern sich gegenseitig täglich per Backup."
],
"correct": 0,
"explanation": "Beim Aktiv/Passiv-Cluster steht der zweite Knoten bereit und übernimmt beim Ausfall (Failover). Aktiv/Aktiv verteilt dagegen die Last auf beide."
},
{
"category": "RTO & RPO",
"hint": "Zeit vs. Datenverlust.",
"text": "Welche Aussage zu RTO und RPO ist korrekt?",
"answers": [
"RTO begrenzt den maximalen Datenverlust, RPO die Wiederherstellungszeit.",
"RTO begrenzt die maximale Wiederherstellungszeit, RPO den maximalen Datenverlust.",
"RTO und RPO bezeichnen dasselbe und sind austauschbar."
],
"correct": 1,
"explanation": "RTO = wie lange darf die Wiederherstellung dauern; RPO = wie alt darf der letzte Datenstand sein (bestimmt die Backup-Frequenz)."
},
{
"category": "RTO & RPO",
"hint": "Backup-Frequenz ableiten.",
"text": "Ein Unternehmen definiert einen RPO von 1 Stunde. Was folgt daraus?",
"answers": [
"Das System muss nach einem Ausfall innerhalb 1 Stunde wieder laufen.",
"Die USV muss das System mindestens 1 Stunde überbrücken.",
"Die Daten müssen mindestens stündlich gesichert bzw. repliziert werden."
],
"correct": 2,
"explanation": "Der RPO begrenzt den maximal tolerierten Datenverlust – bei 1 Stunde RPO darf der letzte Sicherungsstand höchstens 1 Stunde alt sein."
}
]
}
+12 -1
View File
@@ -21,7 +21,18 @@
try { localStorage.setItem(KEY, next); } catch (e) {} try { localStorage.setItem(KEY, next); } catch (e) {}
paint(); paint();
} }
window.MindfluxTheme = { toggle: toggle, apply: apply }; function set(mode) {
var next = mode === 'light' ? 'light' : 'dark';
apply(next);
try { localStorage.setItem(KEY, next); } catch (e) {}
paint();
}
window.MindfluxTheme = {
toggle: toggle,
apply: apply,
set: set,
current: function () { return document.documentElement.getAttribute('data-theme'); }
};
// Mobile-Nav: Off-Canvas-Drawer für Seiten mit App-Sidebar (.mf-shell) // Mobile-Nav: Off-Canvas-Drawer für Seiten mit App-Sidebar (.mf-shell)
function initMobileNav() { function initMobileNav() {
@@ -30,12 +30,14 @@
<div class="mf-container"> <div class="mf-container">
<div class="mf-page-head"> <div class="mf-page-head">
<a href="/profile" class="mf-back">⟵ Zurück zum Profil</a> <a th:href="@{/dashboard}" class="mf-back">⟵ Zurück zum Dashboard</a>
</div> </div>
<span class="mf-eyebrow">Deine Trophäen</span> <span class="mf-eyebrow">Deine Erfolge</span>
<h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">🏆 Deine Trophäen</h1> <h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">🏆 Deine Erfolge</h1>
<p style="color:var(--muted); margin-bottom:1.4rem;">Sammle sie alle und werde zur Legende!</p> <p style="color:var(--muted); margin-bottom:1.4rem;">Sammle sie alle und werde zur Legende!</p>
<nav th:replace="~{fragments/layout :: profileTabs('erfolge')}"></nav>
<div class="mf-panel"> <div class="mf-panel">
<div class="mf-ach-grid"> <div class="mf-ach-grid">
<div class="achievement-card" <div class="achievement-card"
+11 -69
View File
@@ -35,68 +35,8 @@
<body> <body>
<div class="mf-shell"> <div class="mf-shell">
<!-- ===== Sidebar ===== --> <!-- ===== Sidebar (gemeinsames Fragment) ===== -->
<aside class="mf-sidebar"> <aside th:replace="~{fragments/layout :: sidebar('dashboard')}"></aside>
<a th:href="@{/dashboard}" class="mf-logo">
<span class="mark"></span>
<span class="txt"><b>MINDFLUX</b><span>KNOWLEDGE. CODE. WIN.</span></span>
</a>
<nav class="mf-nav">
<a th:href="@{/dashboard}" class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
Dashboard
</a>
<a th:href="@{/admin}" sec:authorize="hasRole('ADMIN')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l8 4v6c0 5-3.5 8-8 10-4.5-2-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>
Admin-Dashboard
</a>
<a th:href="@{/profile/knowledge}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18"/></svg>
Knowledge Core
</a>
<a th:href="@{/progress}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg>
Statistiken
</a>
<a th:href="@{/profile/achievements}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="9" r="6"/><path d="M9 14l-2 8 5-3 5 3-2-8"/></svg>
Achievements
</a>
<a th:href="@{/profile/settings}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3.2"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 0 0-1.7-1L14.5 2h-4l-.3 2.5a7 7 0 0 0-1.7 1l-2.4-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7 7 0 0 0 1.7-1l2.4 1 2-3.4-2-1.6c.1-.3.1-.7.1-1z"/></svg>
Einstellungen
</a>
</nav>
<a th:href="@{/profile}" class="mf-profile" style="text-decoration:none">
<div class="row">
<span class="mf-avatar" th:text="${#authentication.name != null ? #authentication.name.substring(0,1).toUpperCase() : 'U'}">A</span>
<div>
<b sec:authentication="name">Alice Darkest</b>
<span class="role" sec:authorize="hasRole('ADMIN')">Administrator</span>
<span class="role" sec:authorize="!hasRole('ADMIN')">Mitglied</span>
</div>
<svg style="margin-left:auto;color:var(--muted);width:18px;height:18px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</div>
</a>
<div class="mf-meta">
<div class="lvl">LEVEL <b>12</b></div>
<div class="xp">3250 / 5000 XP</div>
<div class="mf-bar"><i style="width:65%"></i></div>
</div>
<div class="mf-streak">
<span class="fire">🔥</span><span><b>STREAK</b><small>7 Tage in Folge</small></span>
</div>
<div class="mf-brain">
<svg viewBox="0 0 120 110" fill="none" stroke="var(--accent)" stroke-width="1.4">
<path d="M60 12c-14-8-34-2-36 14-10 4-12 18-4 26-4 10 2 22 14 24 4 8 18 10 26 4 8 6 22 4 26-4 12-2 18-14 14-24 8-8 6-22-4-26-2-16-22-22-36-14z"/>
<path d="M60 16v82M40 30c8 4 8 14 0 18M80 30c-8 4-8 14 0 18M34 60c10 0 14 8 14 16M86 60c-10 0-14 8-14 16M48 44h24M44 76h32"/>
</svg>
</div>
</aside>
<!-- ===== Topbar ===== --> <!-- ===== Topbar ===== -->
<header class="mf-topbar"> <header class="mf-topbar">
@@ -111,7 +51,9 @@
<button class="mf-iconbtn" aria-label="Benachrichtigungen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button> <button class="mf-iconbtn" aria-label="Benachrichtigungen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>
<div class="mf-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="Suche..."></div> <div class="mf-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="Suche..."></div>
<button class="mf-theme"></button> <button class="mf-theme"></button>
<a class="mf-iconbtn" href="/logout" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></a> <form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="mf-iconbtn" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
</form>
</div> </div>
</header> </header>
@@ -186,12 +128,12 @@
const GAMES = [ const GAMES = [
['MindMatch','brain','Trainiere dein Gedächtnis mit IT-Begriffen in einem klassischen Memory-Spiel.','/mindmatch'], ['MindMatch','brain','Trainiere dein Gedächtnis mit IT-Begriffen in einem klassischen Memory-Spiel.','/mindmatch'],
['TechTacular','code','Das IT-Quizduell im Jeopardy-Stil – teste dein Wissen in verschiedenen Kategorien.','/techtacular'], ['TechTacular','code','Das IT-Quizduell im Jeopardy-Stil – teste dein Wissen in verschiedenen Kategorien.','/techtacular'],
['Dev Lab','lab','Das Entwicklerlabor bündelt Java, SQL, CSS, PHP, HTML, Git, REST und Regex.','/devlab'], ['DevLab','lab','Das Entwicklerlabor bündelt Java, SQL, CSS, PHP, HTML, Git, REST und Regex.','/devlab'],
['Syntax Saga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'], ['SyntaxSaga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'],
['Code Escape Room','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'], ['CodeEscape','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'],
['Service Clash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'], ['ServiceClash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'],
['Bug Hunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'], ['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'],
['Netzwerker – Das Router-Rätselspiel','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'], ['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'],
['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux'] ['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux']
]; ];
document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>` document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>`
@@ -18,7 +18,9 @@
</div> </div>
<div class="dlg-header-tools"> <div class="dlg-header-tools">
<button type="button" class="mf-theme"></button> <button type="button" class="mf-theme"></button>
<a href="/logout" class="dlg-logout">Logout</a> <form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="dlg-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
</form>
</div> </div>
</header> </header>
+4 -2
View File
@@ -41,12 +41,14 @@
<header class="mf-topbar dl-topbar"> <header class="mf-topbar dl-topbar">
<div class="mf-hello"> <div class="mf-hello">
<p class="mf-eyebrow">Entwicklerlabor</p> <p class="mf-eyebrow">Entwicklerlabor</p>
<h1>Dev Lab</h1> <h1>DevLab</h1>
<p>Ein zentraler Hub für die Spiele aus Syntax, Debugging und visueller Praxis.</p> <p>Ein zentraler Hub für die Spiele aus Syntax, Debugging und visueller Praxis.</p>
</div> </div>
<div class="mf-tools"> <div class="mf-tools">
<button type="button" class="mf-theme"></button> <button type="button" class="mf-theme"></button>
<a class="mf-iconbtn" href="/logout" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></a> <form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="mf-iconbtn" aria-label="Logout" style="color:var(--danger)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg></button>
</form>
</div> </div>
</header> </header>
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mindflux – Fehler</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<div class="mf-container" style="max-width:640px; margin:10vh auto 0;">
<div class="mf-panel" style="text-align:center; padding:2.5rem 2rem;">
<div style="font-size:2.6rem; margin-bottom:.6rem;">⚡</div>
<h1 style="color:var(--heading); font-size:1.6rem; font-weight:800; margin-bottom:.4rem;">
Ups – da ist etwas schiefgelaufen
</h1>
<p style="color:var(--muted); margin-bottom:1rem;">
<span th:if="${status != null}" th:text="'Fehlercode ' + ${status}">Fehlercode</span>
<span th:unless="${status != null}">Unerwarteter Fehler</span>
</p>
<p th:if="${friendlyMessage != null}" th:text="${friendlyMessage}"
style="color:var(--text); margin-bottom:1.4rem;"></p>
<a th:href="@{/dashboard}" class="mf-btn">Zum Dashboard</a>
</div>
</div>
</body>
</html>
+12 -2
View File
@@ -16,9 +16,9 @@
<button class="mf-theme"></button> <button class="mf-theme"></button>
</div> </div>
<h1>FinalFlux</h1> <h1>FinalFlux</h1>
<p>AP1 Informatik nach aktueller IHK-Ausbildungsordnung.</p> <p>AP1 &amp; AP2 nach aktueller IHK-Ausbildungsordnung – die gesamte Fachinformatiker-Ausbildung.</p>
<nav class="finalflux-nav" aria-label="FinalFlux AP1 Navigation"> <nav class="finalflux-nav" aria-label="FinalFlux Navigation">
<section> <section>
<h2>Grundlagen</h2> <h2>Grundlagen</h2>
<a href="/finalflux/ap1/zahlensysteme.html">Zahlensysteme & Speicher</a> <a href="/finalflux/ap1/zahlensysteme.html">Zahlensysteme & Speicher</a>
@@ -58,6 +58,16 @@
<a href="/finalflux/wirtschaft/harvard_konzept.html">Harvard-Konzept</a> <a href="/finalflux/wirtschaft/harvard_konzept.html">Harvard-Konzept</a>
<a href="/finalflux/wirtschaft/win_win_strategie.html">Win-Win-Strategie</a> <a href="/finalflux/wirtschaft/win_win_strategie.html">Win-Win-Strategie</a>
</section> </section>
<section>
<h2>AP2 Anwendungsentwicklung</h2>
<a href="/finalflux/ap2-fiae/entwurfsmuster.html">Entwurfsmuster</a>
</section>
<section>
<h2>AP2 Systemintegration</h2>
<a href="/finalflux/ap2-fisi/hochverfuegbarkeit.html">Hochverfügbarkeit</a>
</section>
</nav> </nav>
</aside> </aside>
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<!-- Gemeinsame Layout-Fragmente: App-Sidebar, Profil-Tab-Leiste, Footer.
Einbinden mit th:replace="~{fragments/layout :: sidebar('dashboard')}" usw. -->
<html lang="de" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<body>
<!-- ===== App-Sidebar (Dashboard-Shell). 'active' markiert den aktuellen Eintrag.
Die XP/Streak-Widgets erscheinen nur, wenn die Seite 'overall' (ProgressData) liefert. ===== -->
<aside class="mf-sidebar" th:fragment="sidebar(active)">
<a th:href="@{/dashboard}" class="mf-logo">
<span class="mark"></span>
<span class="txt"><b>MINDFLUX</b><span>KNOWLEDGE. CODE. WIN.</span></span>
</a>
<nav class="mf-nav">
<a th:href="@{/dashboard}" th:classappend="${active == 'dashboard'} ? 'active'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
Dashboard
</a>
<a th:href="@{/admin}" sec:authorize="hasRole('ADMIN')" th:classappend="${active == 'admin'} ? 'active'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l8 4v6c0 5-3.5 8-8 10-4.5-2-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>
Admin-Dashboard
</a>
<a th:href="@{/profile}" th:classappend="${active == 'profil'} ? 'active'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-6.5 8-6.5S20 17 20 21"/></svg>
Profil
</a>
<a th:href="@{/profile/knowledge}" th:classappend="${active == 'knowledge'} ? 'active'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18"/></svg>
Knowledge Core
</a>
<a th:href="@{/progress}" th:classappend="${active == 'statistiken'} ? 'active'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg>
Statistiken
</a>
<a th:href="@{/profile/achievements}" th:classappend="${active == 'erfolge'} ? 'active'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="9" r="6"/><path d="M9 14l-2 8 5-3 5 3-2-8"/></svg>
Erfolge
</a>
<a th:href="@{/profile/settings}" th:classappend="${active == 'einstellungen'} ? 'active'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3.2"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 0 0-1.7-1L14.5 2h-4l-.3 2.5a7 7 0 0 0-1.7 1l-2.4-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7 7 0 0 0 1.7-1l2.4 1 2-3.4-2-1.6c.1-.3.1-.7.1-1z"/></svg>
Einstellungen
</a>
</nav>
<!-- Profil-Shortcut (ohne Dropdown-Pfeil – es gibt kein Dropdown) -->
<a th:href="@{/profile}" class="mf-profile" style="text-decoration:none">
<div class="row">
<span class="mf-avatar" th:text="${#authentication.name != null ? #authentication.name.substring(0,1).toUpperCase() : 'U'}">A</span>
<div>
<b sec:authentication="name">Benutzer</b>
<span class="role" sec:authorize="hasRole('ADMIN')">Administrator</span>
<span class="role" sec:authorize="!hasRole('ADMIN')">Mitglied</span>
</div>
</div>
</a>
<div class="mf-meta" th:if="${overall != null}">
<div class="lvl">LEVEL <b th:text="${overall.userLevel}">1</b></div>
<div class="xp" th:text="${overall.currentXp} + ' / ' + ${overall.xpForNextLevel} + ' XP'">0 / 1000 XP</div>
<div class="mf-bar"><i th:style="'width:' + ${overall.progressPercent} + '%'"></i></div>
</div>
<div class="mf-streak" th:if="${overall != null}">
<span class="fire">🔥</span>
<span><b>STREAK</b><small th:text="${overall.streakDays} + (${overall.streakDays == 1} ? ' Tag' : ' Tage') + ' in Folge'">0 Tage in Folge</small></span>
</div>
<div class="mf-brain">
<svg viewBox="0 0 120 110" fill="none" stroke="var(--accent)" stroke-width="1.4">
<path d="M60 12c-14-8-34-2-36 14-10 4-12 18-4 26-4 10 2 22 14 24 4 8 18 10 26 4 8 6 22 4 26-4 12-2 18-14 14-24 8-8 6-22-4-26-2-16-22-22-36-14z"/>
<path d="M60 16v82M40 30c8 4 8 14 0 18M80 30c-8 4-8 14 0 18M34 60c10 0 14 8 14 16M86 60c-10 0-14 8-14 16M48 44h24M44 76h32"/>
</svg>
</div>
</aside>
<!-- ===== Profilbereich: Tab-Leiste, auf ALLEN Unterseiten identisch ===== -->
<nav class="mf-tab-nav" th:fragment="profileTabs(active)">
<a th:href="@{/profile}" class="mf-tab" th:classappend="${active == 'profil'} ? ' active'">Profil</a>
<a th:href="@{/progress}" class="mf-tab" th:classappend="${active == 'statistiken'} ? ' active'">Statistiken</a>
<a th:href="@{/profile/achievements}" class="mf-tab" th:classappend="${active == 'erfolge'} ? ' active'">Erfolge</a>
<a th:href="@{/soziales}" class="mf-tab" th:classappend="${active == 'soziales'} ? ' active'">Soziales</a>
<a th:href="@{/profile/settings}" class="mf-tab" th:classappend="${active == 'einstellungen'} ? ' active'">Einstellungen</a>
</nav>
<!-- ===== Footer ===== -->
<footer class="mf-footer" th:fragment="footer">
<a href="#">Support</a>
<a href="#">Feedback</a>
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
<a href="/richtlinien">Richtlinien</a>
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
</footer>
</body>
</html>
+3 -1
View File
@@ -59,6 +59,8 @@
<div th:if="${param.registerError}" class="mf-alert err">Benutzername bereits vergeben.</div> <div th:if="${param.registerError}" class="mf-alert err">Benutzername bereits vergeben.</div>
<div th:if="${param.passwordMismatch}" class="mf-alert err">Die Passwörter stimmen nicht überein. Bitte erneut eingeben.</div> <div th:if="${param.passwordMismatch}" class="mf-alert err">Die Passwörter stimmen nicht überein. Bitte erneut eingeben.</div>
<div th:if="${param.passwordPolicy}" class="mf-alert err">Das Passwort muss mindestens 8 Zeichen lang sein.</div>
<div th:if="${param.emailInvalid}" class="mf-alert err">Bitte gib eine gültige E-Mail-Adresse an.</div>
<form th:action="@{/register}" method="post"> <form th:action="@{/register}" method="post">
<div class="mf-field"> <div class="mf-field">
@@ -71,7 +73,7 @@
</div> </div>
<div class="mf-field"> <div class="mf-field">
<label class="mf-label" for="reg-password">Passwort</label> <label class="mf-label" for="reg-password">Passwort</label>
<input id="reg-password" class="mf-input" type="password" name="password" placeholder="Passwort" required> <input id="reg-password" class="mf-input" type="password" name="password" placeholder="Passwort (min. 8 Zeichen)" required minlength="8">
</div> </div>
<div class="mf-field"> <div class="mf-field">
<label class="mf-label" for="reg-password2">Passwort wiederholen</label> <label class="mf-label" for="reg-password2">Passwort wiederholen</label>
-67
View File
@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login – Mindflux</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 400px;
margin: 50px auto;
padding: 2rem;
border: 1px solid #ccc;
border-radius: 8px;
}
h2 {
text-align: center;
}
label {
display: block;
margin: 1rem 0 0.3rem;
}
input {
width: 100%;
padding: 0.5rem;
}
button {
margin-top: 1rem;
width: 100%;
padding: 0.7rem;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.message {
margin-top: 1rem;
text-align: center;
}
.error {
color: red;
}
</style>
</head>
<body>
<h2>Login</h2>
<form th:action="@{/login}" method="post">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required th:value="${param.username}"/>
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required/>
<div class="message error" th:if="${param.error}">
Benutzername oder Passwort sind falsch.
</div>
<div class="message" th:if="${param.logout}">
Du hast dich erfolgreich abgemeldet.
</div>
<button type="submit">Einloggen</button>
</form>
</body>
</html>
+3 -1
View File
@@ -18,7 +18,9 @@
<h1 class="mm-title">MindMatch</h1> <h1 class="mm-title">MindMatch</h1>
<div class="mm-header-tools"> <div class="mm-header-tools">
<button type="button" class="mf-theme"></button> <button type="button" class="mf-theme"></button>
<a href="/logout" class="mm-logout">Logout</a> <form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="mm-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
</form>
</div> </div>
</header> </header>
+1 -11
View File
@@ -7,10 +7,6 @@
<link rel="stylesheet" href="/css/theme.css"> <link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script> <script src="/js/theme.js"></script>
<style> <style>
.mf-tab-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-bottom:1.4rem; }
.mf-tab { padding:.55rem .95rem; border-radius:10px; border:1px solid var(--border); color:var(--muted); text-decoration:none; font-size:.85rem; font-weight:700; transition:all .18s ease; }
.mf-tab:hover { color:var(--text); background:var(--surface-2); }
.mf-tab.active { color:var(--accent-ink); background:linear-gradient(100deg,var(--accent),var(--accent-2)); box-shadow:0 0 18px var(--glow-s); border-color:transparent; }
.mf-profile-pic { display:flex; justify-content:center; margin-bottom:1.3rem; } .mf-profile-pic { display:flex; justify-content:center; margin-bottom:1.3rem; }
.mf-profile-pic img { width:140px; height:140px; border-radius:50%; object-fit:cover; border:2px solid var(--border-accent); box-shadow:0 0 22px var(--glow-w); } .mf-profile-pic img { width:140px; height:140px; border-radius:50%; object-fit:cover; border:2px solid var(--border-accent); box-shadow:0 0 22px var(--glow-w); }
.mf-success { margin-bottom:1.2rem; transition:opacity 1.5s ease; } .mf-success { margin-bottom:1.2rem; transition:opacity 1.5s ease; }
@@ -32,13 +28,7 @@
<h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">👤 Mein Profil</h1> <h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">👤 Mein Profil</h1>
<p style="color:var(--muted); margin-bottom:1.4rem;">Alles rund um dich und deinen Fortschritt</p> <p style="color:var(--muted); margin-bottom:1.4rem;">Alles rund um dich und deinen Fortschritt</p>
<nav class="mf-tab-nav"> <nav th:replace="~{fragments/layout :: profileTabs('profil')}"></nav>
<a th:href="@{/profile}" class="mf-tab active">Profil</a>
<a th:href="@{/progress}" class="mf-tab">Fortschritt</a>
<a th:href="@{/profile/achievements}" class="mf-tab">Erfolge</a>
<a th:href="@{/soziales}" class="mf-tab">Soziales</a>
<a th:href="@{/profile/settings}" class="mf-tab">Einstellungen</a>
</nav>
<!-- Erfolgsmeldung --> <!-- Erfolgsmeldung -->
<div th:if="${message}" class="mf-panel accent mf-success" id="successMessage"> <div th:if="${message}" class="mf-panel accent mf-success" id="successMessage">
+5 -3
View File
@@ -33,12 +33,14 @@
<div class="mf-container"> <div class="mf-container">
<div class="mf-page-head"> <div class="mf-page-head">
<a href="/profile" class="mf-back">⟵ Zurück zum Profil</a> <a th:href="@{/dashboard}" class="mf-back">⟵ Zurück zum Dashboard</a>
</div> </div>
<span class="mf-eyebrow">Dein Fortschritt</span> <span class="mf-eyebrow">Deine Statistiken</span>
<h1 style="color:var(--heading); font-size:2.1rem; font-weight:800; margin:.3rem 0 .25rem;">Dein Fortschritt</h1> <h1 style="color:var(--heading); font-size:2.1rem; font-weight:800; margin:.3rem 0 .25rem;">Deine Statistiken</h1>
<p style="color:var(--muted); margin-bottom:1.4rem;">Bleib am Ball – jeder Schritt zählt!</p> <p style="color:var(--muted); margin-bottom:1.4rem;">Bleib am Ball – jeder Schritt zählt!</p>
<nav th:replace="~{fragments/layout :: profileTabs('statistiken')}"></nav>
<!-- Gesamtfortschritt --> <!-- Gesamtfortschritt -->
<section class="mf-panel accent" style="margin-bottom:1.2rem;"> <section class="mf-panel accent" style="margin-bottom:1.2rem;">
<div class="mf-progress-summary"> <div class="mf-progress-summary">
@@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mindflux – Registrieren</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<style>
.mf-authlogo { display:flex; flex-direction:column; align-items:center; gap:.5rem; margin-bottom:1.4rem; text-align:center; }
.mf-authlogo .mark { width:60px; height:60px; background:var(--accent);
-webkit-mask:url("/img/mindflux-emblem.png") center/contain no-repeat; mask:url("/img/mindflux-emblem.png") center/contain no-repeat;
filter:drop-shadow(0 0 12px var(--glow-s)); }
.mf-authlogo b { color:var(--accent); font-weight:800; letter-spacing:.16em; font-size:1.15rem; text-shadow:0 0 14px var(--glow-s); }
.mf-authlogo span { color:var(--muted); font-size:.62rem; letter-spacing:.24em; }
.mf-cardbox h2 { color:var(--heading); font-size:1.15rem; font-weight:800; margin-bottom:1.1rem; text-align:center; }
.mf-cardbox .mf-btn { width:100%; }
.mf-alert { padding:.7rem .9rem; border-radius:10px; font-size:.86rem; margin-bottom:1rem; }
.mf-alert.err { color:var(--danger); border:1px solid var(--danger); background:rgba(255,95,107,.08); }
.mf-alert.ok { color:var(--accent); border:1px solid var(--border-accent); background:var(--surface-2); }
</style>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<div class="mf-center">
<div class="mf-cardbox">
<div class="mf-authlogo">
<span class="mark"></span>
<b>MINDFLUX</b>
<span>REGISTRIERUNG</span>
</div>
<h2>📝 Account erstellen</h2>
<!-- Fehler anzeigen -->
<div th:if="${error}" class="mf-alert err">
<span th:text="${error}"></span>
</div>
<!-- Erfolgreiche Registrierung anzeigen -->
<div th:if="${param.registerSuccess}" class="mf-alert ok">
Registrierung erfolgreich! Du kannst dich jetzt einloggen.
</div>
<form th:action="@{/register}" method="post">
<div class="mf-field">
<label class="mf-label" for="username">Benutzername</label>
<input id="username" class="mf-input" type="text" name="username" placeholder="Benutzername" required>
</div>
<div class="mf-field">
<label class="mf-label" for="password">Passwort</label>
<input id="password" class="mf-input" type="password" name="password" placeholder="Passwort" required>
</div>
<div class="mf-field">
<label class="mf-label" for="email">E-Mail</label>
<input id="email" class="mf-input" type="email" name="email" placeholder="E-Mail" required>
</div>
<button type="submit" class="mf-btn">Registrieren</button>
</form>
<p style="text-align:center;margin-top:1.1rem">
<a href="/" class="mf-back">⟵ Zurück zum Login</a>
</p>
</div>
</div>
<footer class="mf-footer">
<a href="#">Support</a>
<a href="#">Feedback</a>
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
<a href="/richtlinien">Richtlinien</a>
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
</footer>
</body>
</html>
+22 -27
View File
@@ -23,50 +23,33 @@
<div class="mf-container"> <div class="mf-container">
<div class="mf-page-head"> <div class="mf-page-head">
<a th:href="@{/profile}" class="mf-back">⟵ Zurück zum Profil</a> <a th:href="@{/dashboard}" class="mf-back">⟵ Zurück zum Dashboard</a>
</div> </div>
<span class="mf-eyebrow">Einstellungen</span> <span class="mf-eyebrow">Einstellungen</span>
<h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">⚙️ Einstellungen</h1> <h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">⚙️ Einstellungen</h1>
<p style="color:var(--muted); margin-bottom:1.4rem;">Passe Mindflux nach deinen Wünschen an</p> <p style="color:var(--muted); margin-bottom:1.4rem;">Passe Mindflux nach deinen Wünschen an</p>
<nav th:replace="~{fragments/layout :: profileTabs('einstellungen')}"></nav>
<!-- Flash-Meldungen (z. B. nach Passwortänderung) --> <!-- Flash-Meldungen (z. B. nach Passwortänderung) -->
<div th:if="${message}" class="mf-panel accent mf-success" style="margin-bottom:1.2rem;"> <div th:if="${message}" class="mf-panel accent mf-success" style="margin-bottom:1.2rem;">
<p th:text="${message}" style="color:var(--accent); margin:0;"></p> <p th:text="${message}" style="color:var(--accent); margin:0;"></p>
</div> </div>
<div th:if="${error}" class="mf-panel mf-error" style="margin-bottom:1.2rem;"> <div th:if="${error}" class="mf-panel mf-error" style="margin-bottom:1.2rem;">
<p th:text="${error}" style="color:#c0392b; margin:0;"></p> <p th:text="${error}" style="color:var(--danger); margin:0;"></p>
</div> </div>
<!-- Theme-Einstellung (Dummy) --> <!-- Theme-Einstellung (an theme.js gekoppelt, wird in localStorage gespeichert) -->
<section class="mf-panel" style="margin-bottom:1.2rem;"> <section class="mf-panel" style="margin-bottom:1.2rem;">
<h2 class="mf-section-title">Theme wechseln</h2> <h2 class="mf-section-title">Theme wechseln</h2>
<div class="mf-field"> <div class="mf-field">
<label class="mf-label">Modus auswählen:</label> <label class="mf-label" for="theme-select">Modus auswählen:</label>
<select class="mf-select"> <select class="mf-select" id="theme-select">
<option>Standard (Dark)</option> <option value="dark">Standard (Dark)</option>
<option>Hell (Light)</option> <option value="light">Hell (Light)</option>
</select> </select>
</div> </div>
<small class="mf-hint">Hinweis: Nutze den Umschalter oben rechts, um zwischen Dark (Grün) und Light (Magenta) zu wechseln.</small> <small class="mf-hint">Die Auswahl gilt sofort und wird auf diesem Gerät gespeichert.</small>
</section>
<!-- Spracheinstellung (Dummy) -->
<section class="mf-panel" style="margin-bottom:1.2rem;">
<h2 class="mf-section-title">Sprache</h2>
<div class="mf-field">
<label class="mf-label">Sprache auswählen:</label>
<select class="mf-select">
<option>Deutsch</option>
<option>Englisch</option>
</select>
</div>
</section>
<!-- Benachrichtigungen (Dummy) -->
<section class="mf-panel" style="margin-bottom:1.2rem;">
<h2 class="mf-section-title">Benachrichtigungen</h2>
<label class="mf-check"><input type="checkbox" checked> E-Mail-Benachrichtigungen erhalten</label>
<label class="mf-check"><input type="checkbox"> Erinnerungen an Lernziele aktivieren</label>
</section> </section>
<!-- Passwort ändern --> <!-- Passwort ändern -->
@@ -88,6 +71,18 @@
</div> </div>
<script>
// Theme-Select mit dem zentralen Umschalter (theme.js) koppeln.
window.addEventListener('DOMContentLoaded', function () {
var select = document.getElementById('theme-select');
if (!select || !window.MindfluxTheme) return;
select.value = window.MindfluxTheme.current() === 'light' ? 'light' : 'dark';
select.addEventListener('change', function () {
window.MindfluxTheme.set(select.value);
});
});
</script>
<footer class="mf-footer"> <footer class="mf-footer">
<a href="#">Support</a> <a href="#">Support</a>
<a href="#">Feedback</a> <a href="#">Feedback</a>
+4 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de" data-theme="dark"> <html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -21,9 +21,11 @@
<div class="mf-container"> <div class="mf-container">
<div class="mf-page-head"> <div class="mf-page-head">
<a href="/profile" class="mf-back">⟵ Zurück zum Profil</a> <a th:href="@{/dashboard}" class="mf-back">⟵ Zurück zum Dashboard</a>
</div> </div>
<nav th:replace="~{fragments/layout :: profileTabs('soziales')}"></nav>
<div class="mf-panel accent mf-soziales"> <div class="mf-panel accent mf-soziales">
<h1>„Straßen? Wo wir hingehen, brauchen wir keine Straßen!“ 🚀</h1> <h1>„Straßen? Wo wir hingehen, brauchen wir keine Straßen!“ 🚀</h1>
<p>Das soziale Netzwerk für Mindflux-User befindet sich noch in Entwicklung.<br> <p>Das soziale Netzwerk für Mindflux-User befindet sich noch in Entwicklung.<br>
+4 -2
View File
@@ -13,10 +13,12 @@
<div class="ss-shell"> <div class="ss-shell">
<header class="ss-header"> <header class="ss-header">
<a href="/dashboard" class="ss-back">⟵ Dashboard</a> <a href="/dashboard" class="ss-back">⟵ Dashboard</a>
<h1 class="ss-title">Syntax Saga</h1> <h1 class="ss-title">SyntaxSaga</h1>
<div class="ss-header-tools"> <div class="ss-header-tools">
<button type="button" class="mf-theme"></button> <button type="button" class="mf-theme"></button>
<a href="/logout" class="ss-logout">Logout</a> <form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="ss-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
</form>
</div> </div>
</header> </header>
@@ -16,7 +16,9 @@
<h1 class="tt-title">TechTacular</h1> <h1 class="tt-title">TechTacular</h1>
<div class="tt-header-tools"> <div class="tt-header-tools">
<button type="button" class="mf-theme"></button> <button type="button" class="mf-theme"></button>
<a href="/logout" class="tt-logout">Logout</a> <form th:action="@{/logout}" method="post" style="display:inline">
<button type="submit" class="tt-logout" style="background:none;border:0;cursor:pointer;font:inherit">Logout</button>
</form>
</div> </div>
</header> </header>
@@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benutzer anlegen</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<style>
.mf-cardbox { max-width:480px; }
.mf-cardbox h1 { color:var(--heading); font-size:1.3rem; font-weight:800; margin-bottom:1.3rem; text-align:center; }
.mf-cardbox .mf-btn { width:100%; margin-top:.4rem; }
</style>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<div class="mf-center">
<div class="mf-cardbox">
<h1>Neuen Benutzer erstellen</h1>
<form th:action="@{/users/save}" th:object="${user}" method="post">
<!-- CSRF-Token für Spring Security -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="mf-field">
<label class="mf-label" for="username">Benutzername</label>
<input id="username" class="mf-input" type="text" th:field="*{username}" placeholder="Benutzername" required />
</div>
<div class="mf-field">
<label class="mf-label" for="password">Passwort</label>
<input id="password" class="mf-input" type="password" th:field="*{password}" placeholder="Passwort" required />
</div>
<div class="mf-field">
<label class="mf-label" for="email">E-Mail</label>
<input id="email" class="mf-input" type="email" th:field="*{email}" placeholder="E-Mail" required />
</div>
<div class="mf-field">
<label class="mf-label" for="role">Rolle</label>
<select id="role" class="mf-select" th:field="*{role}">
<option value="USER">USER</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
<!-- Optionales Profilbild-Feld, falls gewünscht -->
<!--
<div class="mf-field">
<label class="mf-label" for="profilePicture">Profilbild-URL</label>
<input id="profilePicture" class="mf-input" type="text" th:field="*{profilePicture}" placeholder="Profilbild-URL" />
</div>
-->
<button type="submit" class="mf-btn">Speichern</button>
</form>
</div>
</div>
<footer class="mf-footer">
<a href="#">Support</a>
<a href="#">Feedback</a>
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
<a href="/richtlinien">Richtlinien</a>
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
</footer>
</body>
</html>
@@ -1,46 +0,0 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benutzer erfolgreich erstellt</title>
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<style>
.mf-cardbox { max-width:480px; text-align:center; }
.mf-check { width:64px; height:64px; margin:0 auto 1rem; color:var(--accent); filter:drop-shadow(0 0 10px var(--glow-s)); }
.mf-cardbox h1 { color:var(--heading); font-size:1.3rem; font-weight:800; margin-bottom:.6rem; }
.mf-cardbox p { color:var(--muted); font-size:.92rem; line-height:1.55; margin-bottom:1.4rem; }
.mf-actions { display:flex; gap:.7rem; justify-content:center; flex-wrap:wrap; }
</style>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<div class="mf-center">
<div class="mf-cardbox">
<svg class="mf-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="M8 12l3 3 5-6"/>
</svg>
<h1>Benutzer gespeichert!</h1>
<p>Der neue Benutzer wurde erfolgreich in der Datenbank gespeichert.</p>
<div class="mf-actions">
<a href="/users/form" class="mf-btn">Neuen Benutzer anlegen</a>
<a href="/api/users" class="mf-btn outline">Alle Benutzer anzeigen (JSON)</a>
</div>
</div>
</div>
<footer class="mf-footer">
<a href="#">Support</a>
<a href="#">Feedback</a>
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
<a href="/richtlinien">Richtlinien</a>
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
</footer>
</body>
</html>
@@ -0,0 +1,81 @@
package com.example.mindflux;
import com.example.mindflux.config.SecurityConfig;
import com.example.mindflux.controller.AdminUserController;
import com.example.mindflux.controller.DashboardController;
import com.example.mindflux.dto.ProgressData;
import com.example.mindflux.repository.UserRepository;
import com.example.mindflux.service.LoginAttemptService;
import com.example.mindflux.service.ProgressService;
import com.example.mindflux.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Absichert die zentralen Security-Regeln: Login-Zwang, Admin-Schutz, gelöschter Legacy-Endpunkt. */
@WebMvcTest(controllers = {DashboardController.class, AdminUserController.class})
@Import(SecurityConfig.class)
class SecurityRulesTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private ProgressService progressService;
@MockitoBean
private UserService userService;
@MockitoBean
private UserRepository userRepository;
@MockitoBean
private LoginAttemptService loginAttemptService;
@Test
void anonymeNutzerWerdenZumLoginUmgeleitet() throws Exception {
mockMvc.perform(get("/dashboard"))
.andExpect(status().is3xxRedirection());
}
@Test
@WithMockUser(username = "alice")
void eingeloggteNutzerSehenDasDashboard() throws Exception {
when(progressService.getOverallProgress(anyString())).thenReturn(new ProgressData());
mockMvc.perform(get("/dashboard"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "alice", roles = "USER")
void adminBereichIstFuerNormaleNutzerGesperrt() throws Exception {
mockMvc.perform(get("/admin/users/new"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void adminsErreichenDenAdminBereich() throws Exception {
mockMvc.perform(get("/admin/users/new"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "alice")
void geloeschterLegacyEndpunktLiefert404() throws Exception {
// K2: /users/save (User-Anlage ohne Hashing/Rollenkontrolle) wurde entfernt.
mockMvc.perform(post("/users/save").with(csrf()))
.andExpect(status().isNotFound());
}
}
@@ -0,0 +1,77 @@
package com.example.mindflux.service;
import com.example.mindflux.model.Achievement;
import com.example.mindflux.repository.AchievementRepository;
import com.example.mindflux.repository.GameScoreRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class AchievementServiceImplTest {
private AchievementRepository achievementRepository;
private GameScoreRepository gameScoreRepository;
private AchievementServiceImpl service;
@BeforeEach
void setUp() {
achievementRepository = mock(AchievementRepository.class);
gameScoreRepository = mock(GameScoreRepository.class);
service = new AchievementServiceImpl(achievementRepository, gameScoreRepository);
when(gameScoreRepository.countByUsername("alice")).thenReturn(1L);
when(gameScoreRepository.sumScoreByUsername("alice")).thenReturn(500L);
when(gameScoreRepository.countDistinctGamesByUsername("alice")).thenReturn(1L);
}
@Test
void erstesSpielVergibtErstenSieg() {
when(achievementRepository.findByUsername("alice")).thenReturn(List.of());
service.checkAndAward("alice", "mindmatch", 500, true, 1);
ArgumentCaptor<Achievement> captor = ArgumentCaptor.forClass(Achievement.class);
verify(achievementRepository, times(1)).save(captor.capture());
assertEquals("Erster Sieg", captor.getValue().getTitle());
assertEquals("alice", captor.getValue().getUsername());
}
@Test
void bereitsVergebeneAchievementsWerdenNichtDoppeltVergeben() {
Achievement vorhanden = new Achievement("Erster Sieg", "…", null, true);
when(achievementRepository.findByUsername("alice")).thenReturn(List.of(vorhanden));
service.checkAndAward("alice", "mindmatch", 500, false, 2);
verify(achievementRepository, never()).save(any());
}
@Test
void hoherEinzelscoreVergibtTausenderClub() {
when(achievementRepository.findByUsername("alice")).thenReturn(List.of());
service.checkAndAward("alice", "techtacular", 1200, true, 1);
ArgumentCaptor<Achievement> captor = ArgumentCaptor.forClass(Achievement.class);
verify(achievementRepository, times(2)).save(captor.capture());
List<String> titel = captor.getAllValues().stream().map(Achievement::getTitle).toList();
assertTrue(titel.contains("Erster Sieg"));
assertTrue(titel.contains("1000er-Club"));
}
@Test
void katalogWirdAlsGesperrteKartenAufgefuellt() {
when(achievementRepository.findByUsername("alice")).thenReturn(List.of());
List<Achievement> alle = service.getAchievementsForUser("alice");
assertEquals(AchievementServiceImpl.CATALOG.size(), alle.size());
assertTrue(alle.stream().allMatch(a -> a.getDateAchieved() == null));
}
}
@@ -0,0 +1,43 @@
package com.example.mindflux.service;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** Reine Rechen-Tests für die serverautoritative MindMatch-Score-Formel. */
class GameScoreCalcTest {
@Test
void perfektesSpielOhneZeitverlustGibtVollePunkte() {
// 8 Paare in 8 Zügen, 0 Sekunden: 1000 Genauigkeit + 500 Zeitbonus
assertEquals(1500, GameScoreService.calculateScore(8, 8, 0));
}
@Test
void zeitbonusFaelltNach100SekundenAufNull() {
assertEquals(1000, GameScoreService.calculateScore(8, 8, 100));
assertEquals(1000, GameScoreService.calculateScore(8, 8, 500));
}
@Test
void mehrZuegeReduzierenDieGenauigkeit() {
// 8 Paare in 16 Zügen: 500 Genauigkeit + 500 Zeitbonus
assertEquals(1000, GameScoreService.calculateScore(8, 16, 0));
}
@Test
void wenigerZuegeAlsPaareWerdenGeklemmt() {
// Unmögliche Eingabe (moves < pairs) darf den Score nicht über das Maximum heben.
assertEquals(1500, GameScoreService.calculateScore(8, 1, 0));
}
@Test
void nullPaareGebenNullPunkte() {
assertEquals(0, GameScoreService.calculateScore(0, 10, 0));
}
@Test
void negativeSekundenZaehlenWieNull() {
assertEquals(1500, GameScoreService.calculateScore(8, 8, -5));
}
}
@@ -0,0 +1,53 @@
package com.example.mindflux.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LoginAttemptServiceTest {
private LoginAttemptService service;
@BeforeEach
void setUp() {
service = new LoginAttemptService();
}
@Test
void sperrtErstNachFuenfFehlversuchen() {
for (int i = 0; i < LoginAttemptService.MAX_ATTEMPTS - 1; i++) {
service.loginFailed("alice");
}
assertFalse(service.isBlocked("alice"));
service.loginFailed("alice");
assertTrue(service.isBlocked("alice"));
}
@Test
void sperreIstCaseInsensitiv() {
for (int i = 0; i < LoginAttemptService.MAX_ATTEMPTS; i++) {
service.loginFailed("Alice");
}
assertTrue(service.isBlocked("alice"));
}
@Test
void erfolgreicherLoginSetztZaehlerZurueck() {
for (int i = 0; i < LoginAttemptService.MAX_ATTEMPTS; i++) {
service.loginFailed("alice");
}
service.loginSucceeded("alice");
assertFalse(service.isBlocked("alice"));
}
@Test
void andereNutzerBleibenUnberuehrt() {
for (int i = 0; i < LoginAttemptService.MAX_ATTEMPTS; i++) {
service.loginFailed("alice");
}
assertFalse(service.isBlocked("bob"));
}
}
@@ -0,0 +1,61 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.ProgressData;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ProgressServiceTest {
private static final LocalDate TODAY = LocalDate.of(2026, 7, 12);
@Test
void streakOhneSpieltageIstNull() {
assertEquals(0, ProgressService.computeStreak(List.of(), TODAY));
}
@Test
void streakZaehltZusammenhaengendeTageBisHeute() {
List<LocalDate> days = List.of(TODAY, TODAY.minusDays(1), TODAY.minusDays(2));
assertEquals(3, ProgressService.computeStreak(days, TODAY));
}
@Test
void streakVonGesternBleibtErhalten() {
// Ein Tag Karenz: Wer heute noch nicht gespielt hat, behält die Serie von gestern.
assertEquals(1, ProgressService.computeStreak(List.of(TODAY.minusDays(1)), TODAY));
}
@Test
void streakBrichtBeiLueckeAb() {
List<LocalDate> days = List.of(TODAY, TODAY.minusDays(2), TODAY.minusDays(3));
assertEquals(1, ProgressService.computeStreak(days, TODAY));
}
@Test
void aeltereSpieltageErgebenKeineStreak() {
assertEquals(0, ProgressService.computeStreak(List.of(TODAY.minusDays(2)), TODAY));
}
@Test
void levelUndXpWerdenAusDerScoresummeBerechnet() {
GameScoreService gameScoreService = mock(GameScoreService.class);
when(gameScoreService.totalScore("alice")).thenReturn(2350L);
when(gameScoreService.totalMoves("alice")).thenReturn(42L);
when(gameScoreService.playDaysDesc("alice")).thenReturn(List.of());
ProgressData data = new ProgressService(gameScoreService).getOverallProgress("alice");
assertEquals(3, data.getUserLevel()); // 2350 XP -> Level 3 (ab 2000)
assertEquals(2350, data.getCurrentXp());
assertEquals(3000, data.getXpForNextLevel()); // Level 4 ab 3000
assertEquals(35, data.getProgressPercent()); // 350 von 1000 im Level
assertEquals(42, data.getCorrectAnswers());
assertEquals(0, data.getStreakDays());
}
}
@@ -0,0 +1,91 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.AchievementRepository;
import com.example.mindflux.repository.GameScoreRepository;
import com.example.mindflux.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class UserServiceImplTest {
private UserRepository userRepository;
private BCryptPasswordEncoder encoder;
private UserServiceImpl service;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
encoder = new BCryptPasswordEncoder();
service = new UserServiceImpl(userRepository,
mock(GameScoreRepository.class), mock(AchievementRepository.class), encoder);
}
@Test
void registrierungLehntZuKurzesPasswortAb() {
assertEquals(RegistrationResult.PASSWORD_TOO_SHORT,
service.register("alice", "a@b.de", "kurz", "kurz"));
verify(userRepository, never()).save(any());
}
@Test
void registrierungLehntAbweichendeBestaetigungAb() {
assertEquals(RegistrationResult.PASSWORD_MISMATCH,
service.register("alice", "a@b.de", "passwort123", "anders123"));
}
@Test
void registrierungLehntUngueltigeEmailAb() {
assertEquals(RegistrationResult.EMAIL_INVALID,
service.register("alice", "keine-mail", "passwort123", "passwort123"));
}
@Test
void registrierungLehntLeerenBenutzernamenAb() {
assertEquals(RegistrationResult.USERNAME_INVALID,
service.register(" ", "a@b.de", "passwort123", "passwort123"));
}
@Test
void registrierungLehntVergebenenNamenCaseInsensitivAb() {
when(userRepository.findByUsernameIgnoreCase("Alice")).thenReturn(Optional.of(new User()));
assertEquals(RegistrationResult.USERNAME_TAKEN,
service.register("Alice", "a@b.de", "passwort123", "passwort123"));
}
@Test
void erfolgreicheRegistrierungSpeichertBcryptUndRolleUser() {
when(userRepository.findByUsernameIgnoreCase("alice")).thenReturn(Optional.empty());
RegistrationResult result = service.register(" alice ", " a@b.de ", "passwort123", "passwort123");
assertEquals(RegistrationResult.SUCCESS, result);
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());
User saved = captor.getValue();
assertEquals("alice", saved.getUsername());
assertEquals("a@b.de", saved.getEmail());
assertEquals("USER", saved.getRole());
assertTrue(encoder.matches("passwort123", saved.getPassword()));
}
@Test
void createUserLehntUnbekannteRolleAb() {
assertThrows(IllegalArgumentException.class,
() -> service.createUser("bob", "b@b.de", "passwort123", "SUPERGOTT"));
}
@Test
void validateRoleFaelltAufUserZurueck() {
assertEquals("USER", service.validateRole(null));
assertEquals("ADMIN", service.validateRole(" admin "));
}
}