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
@@ -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.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
@@ -11,6 +13,8 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class AdminSetup {
private static final Logger log = LoggerFactory.getLogger(AdminSetup.class);
@Value("${MINDFLUX_ADMIN_USERNAME:admin}")
private String adminUsername;
@@ -44,7 +48,7 @@ public class AdminSetup {
return; // Admin-Slot nicht konfiguriert -> still überspringen.
}
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;
}
if (userRepo.findByUsername(username).isEmpty()) {
@@ -54,7 +58,7 @@ public class AdminSetup {
admin.setPassword(passwordEncoder.encode(password));
admin.setRole("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.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
@@ -20,6 +22,8 @@ import java.util.Set;
@Component
public class DevLabSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DevLabSeeder.class);
private static final int MAX_PAYLOAD = 8000;
private final DevLabGameRepository gameRepository;
@@ -40,7 +44,7 @@ public class DevLabSeeder implements CommandLineRunner {
JsonNode root = objectMapper.readTree(new ClassPathResource("devlab/content.json").getInputStream());
JsonNode games = root.get("games");
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;
}
@@ -87,10 +91,10 @@ public class DevLabSeeder implements CommandLineRunner {
gameCount++;
}
}
System.out.println("✅ Dev Lab: " + gameCount + " Spiele / " + taskCount + " Aufgaben geladen"
+ (skipped > 0 ? " (" + skipped + " zu groß, übersprungen)" : "") + ".");
log.info("DevLab: {} Spiele / {} Aufgaben geladen{}.", gameCount, taskCount,
skipped > 0 ? " (" + skipped + " zu groß, übersprungen)" : "");
} 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.repository.UserRepository;
import com.example.mindflux.service.LoginAttemptService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
public class SecurityConfig {
@@ -20,20 +21,21 @@ public class SecurityConfig {
http
.authorizeHttpRequests(auth -> auth
.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")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/") // login.html liegt auf "/"
.loginPage("/") // Login-Formular liegt auf der Startseite (index.html)
.loginProcessingUrl("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/?error") // zeigt Loginfehler an
.permitAll()
)
.logout(logout -> logout
// Logout-Links in den Templates sind GET -> GET-Logout erlauben, sonst 404/Fehler unter CSRF
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
// Logout nur per POST (CSRF-geschützt); die Templates nutzen kleine Formulare.
.logoutUrl("/logout")
.logoutSuccessUrl("/?logout")
.permitAll()
)
@@ -47,8 +49,13 @@ public class SecurityConfig {
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo) {
public UserDetailsService userDetailsService(UserRepository userRepo, LoginAttemptService loginAttemptService) {
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)
.orElseThrow(() -> new UsernameNotFoundException("Benutzer nicht gefunden"));
@@ -6,6 +6,8 @@ import com.example.mindflux.model.SyntaxSagaSolution;
import com.example.mindflux.repository.SyntaxSagaMissionRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
@@ -22,6 +24,8 @@ import java.util.Set;
@Component
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 final SyntaxSagaMissionRepository missionRepository;
@@ -43,7 +47,7 @@ public class SyntaxSagaSeeder implements CommandLineRunner {
new ClassPathResource("syntaxsaga/content.json").getInputStream());
JsonNode missions = root.get("missions");
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;
}
@@ -104,9 +108,9 @@ public class SyntaxSagaSeeder implements CommandLineRunner {
missionCount++;
}
}
System.out.println("✅ Syntax Saga: " + missionCount + " Missionen / " + gapCount + " Lücken geladen.");
log.info("SyntaxSaga: {} Missionen / {} Lücken geladen.", missionCount, gapCount);
} 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.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
@@ -22,6 +24,8 @@ import java.util.Set;
@Component
public class TechtacularSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(TechtacularSeeder.class);
private final TechtacularCategoryRepository categoryRepository;
private final ObjectMapper objectMapper;
@@ -40,7 +44,7 @@ public class TechtacularSeeder implements CommandLineRunner {
JsonNode root = objectMapper.readTree(new ClassPathResource("techtacular/content.json").getInputStream());
JsonNode categories = root.get("categories");
if (categories == null || !categories.isArray()) {
System.out.println("⚠️ TechTacular-Seed: content.json ohne 'categories'.");
log.warn("TechTacular-Seed: content.json ohne 'categories'.");
return;
}
@@ -95,9 +99,9 @@ public class TechtacularSeeder implements CommandLineRunner {
catCount++;
}
}
System.out.println("✅ TechTacular: " + catCount + " Kategorien / " + clueCount + " Hinweise geladen.");
log.info("TechTacular: {} Kategorien / {} Hinweise geladen.", catCount, clueCount);
} 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")
public String showAchievements(Model model, Principal principal) {
String username = principal.getName();
// Kompletter Katalog: verdiente Achievements mit Datum, restliche als gesperrte Karten.
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);
return "achievements"; // lädt achievements.html
@@ -1,8 +1,7 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.AdminUserService;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.mindflux.service.UserService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -12,16 +11,18 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/admin")
public class AdminUserController {
@Autowired
private AdminUserService userService;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@Autowired
private PasswordEncoder passwordEncoder;
public AdminUserController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
// ⬅️ Neu: Dashboard-Startseite
// ⬅️ Dashboard-Startseite
@GetMapping("")
public String adminDashboard() {
return "admin/dashboard"; // du brauchst: templates/admin/dashboard.html
return "admin/dashboard";
}
// ⬅️ Benutzerverwaltung
@@ -42,6 +43,8 @@ public class AdminUserController {
if (user.getId() != null) {
User existingUser = userService.findById(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)
if (user.getPassword() == null || user.getPassword().isBlank()) {
user.setPassword(existingUser.getPassword());
@@ -50,11 +53,11 @@ public class AdminUserController {
}
// Profilbild steht nicht im Formular -> bestehendes erhalten, nicht überschreiben
user.setProfilePicture(existingUser.getProfilePicture());
userService.save(user);
} else {
user.setPassword(passwordEncoder.encode(user.getPassword()));
// Neuanlage zentral über den UserService (Validierung, Eindeutigkeit, Encoding)
userService.createUser(user.getUsername(), user.getEmail(), user.getPassword(), user.getRole());
}
userService.save(user);
return "redirect:/admin/users";
}
@@ -72,7 +75,8 @@ public class AdminUserController {
@GetMapping("/users/edit/{id}")
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);
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;
import com.example.mindflux.service.ProgressService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
@Controller
public class DashboardController {
private final ProgressService progressService;
public DashboardController(ProgressService progressService) {
this.progressService = progressService;
}
@GetMapping("/dashboard")
public String showDashboard() {
return "dashboard"; // lädt dashboard.html aus templates
public String showDashboard(Model model, Principal principal) {
// 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). */
private static final Set<String> KNOWN_GAMES = Set.of(
"mindmatch", "techtacular", "syntax-saga", "devlab", "escape-room",
"service-clash", "bug-hunter", "netzwerker");
"mindmatch", "techtacular", "syntax-saga", "devlab", "codeescape",
"serviceclash", "bughunter", "networker");
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.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -29,6 +31,8 @@ import java.util.UUID;
@Controller
public class ProfileController {
private static final Logger log = LoggerFactory.getLogger(ProfileController.class);
@Autowired
private UserService userService;
@@ -61,19 +65,17 @@ public class ProfileController {
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
System.out.println("Versuche zu löschen: " + picturePath.toAbsolutePath());
if (Files.exists(picturePath)) {
Files.delete(picturePath);
System.out.println("Datei erfolgreich gelöscht: " + picturePath.getFileName());
log.info("Profilbild gelöscht: {}", picturePath.getFileName());
} else {
System.out.println("Datei existiert nicht: " + picturePath.toAbsolutePath());
log.warn("Profilbild-Datei existiert nicht: {}", picturePath.toAbsolutePath());
}
} 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 {
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
@@ -83,6 +85,12 @@ public class ProfileController {
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")
public String knowledgeCenter(Model model, Principal principal) {
String username = principal.getName();
@@ -107,7 +115,7 @@ public class ProfileController {
BufferedImage original = ImageIO.read(file.getInputStream());
if (original == null) {
// 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 {
// Auf max. Kantenlänge herunterskalieren (kein Hochskalieren).
final int MAX_DIM = 400;
@@ -141,7 +149,7 @@ public class ProfileController {
existingUser.setProfilePicture(fileName);
}
} 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);
Files.deleteIfExists(picturePath);
} 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);
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;
import com.example.mindflux.model.User;
import com.example.mindflux.service.RegistrationResult;
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.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class RegisterController {
@Autowired
private UserService userService;
private final UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
public RegisterController(UserService userService) {
this.userService = userService;
}
@GetMapping("/register")
public String showRegistrationForm(Model model) {
model.addAttribute("user", new User());
return "register";
public String showRegistrationForm() {
// Die eigenständige Registrierungsseite wurde entfernt – das Formular lebt auf der Startseite.
return "redirect:/";
}
@PostMapping("/register")
public String processRegistration(@ModelAttribute("user") User user,
@RequestParam(value = "confirmPassword", required = false) String confirmPassword) {
// Schreibweise des Benutzernamens beibehalten, nur trimmen
String username = user.getUsername() == null ? "" : user.getUsername().trim();
user.setUsername(username);
// Passwörter müssen übereinstimmen (Schutz vor Tippfehlern bei der Eingabe)
if (user.getPassword() == null || user.getPassword().isBlank()
|| !user.getPassword().equals(confirmPassword)) {
return "redirect:/?passwordMismatch";
}
// 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
public String processRegistration(@RequestParam String username,
@RequestParam String email,
@RequestParam String password,
@RequestParam(required = false) String confirmPassword) {
RegistrationResult result = userService.register(username, email, password, confirmPassword);
return switch (result) {
case SUCCESS -> "redirect:/?registerSuccess";
case PASSWORD_MISMATCH -> "redirect:/?passwordMismatch";
case PASSWORD_TOO_SHORT -> "redirect:/?passwordPolicy";
case EMAIL_INVALID -> "redirect:/?emailInvalid";
case USERNAME_INVALID, USERNAME_TAKEN -> "redirect:/?registerError";
};
}
}
}
@@ -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;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.mindflux.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserRepository userRepository;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@Autowired
private PasswordEncoder passwordEncoder;
public UserController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
// 📥 User erstellen
// 📥 User erstellen (Validierung, Eindeutigkeit und Encoding zentral im UserService)
@PostMapping
public User createUser(@RequestBody User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
return userService.createUser(user.getUsername(), user.getEmail(), user.getPassword(), user.getRole());
}
// 📋 Alle User anzeigen
@GetMapping
public List<User> getAllUsers() {
return userRepository.findAll();
return userService.findAll();
}
// 📍 Einzelnen User anzeigen
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
Optional<User> user = userRepository.findById(id);
return user.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
return userService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
// ✏️ User aktualisieren
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User updatedUser) {
return userRepository.findById(id).map(user -> {
user.setUsername(updatedUser.getUsername());
return userService.findById(id).map(user -> {
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()) {
user.setPassword(passwordEncoder.encode(updatedUser.getPassword()));
}
user.setEmail(updatedUser.getEmail());
user.setRole(updatedUser.getRole());
return ResponseEntity.ok(userRepository.save(user));
user.setRole(userService.validateRole(updatedUser.getRole()));
userService.save(user);
return ResponseEntity.ok(user);
}).orElseGet(() -> ResponseEntity.notFound().build());
}
// 🗑️ User löschen
// 🗑️ User löschen (inkl. zugehöriger Scores/Achievements)
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userRepository.existsById(id)) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
if (userService.findById(id).isEmpty()) {
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 userLevel;
private int progressPercent;
private int streakDays;
public int getStreakDays() {
return streakDays;
}
public void setStreakDays(int streakDays) {
this.streakDays = streakDays;
}
public int getCorrectAnswers() {
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 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;
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). */
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 org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
@@ -12,4 +13,8 @@ public interface TechtacularCategoryRepository extends JpaRepository<Techtacular
List<TechtacularCategory> findAllByOrderBySortOrderAscIdAsc();
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 org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface TechtacularQuestionRepository extends JpaRepository<TechtacularQuestion, Long> {
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;
public interface AchievementService {
/** Kompletter Katalog: verdiente Achievements mit Datum, restliche als gesperrt. */
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.repository.AchievementRepository;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.mindflux.repository.GameScoreRepository;
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.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class AchievementServiceImpl implements AchievementService {
@Autowired
private AchievementRepository achievementRepository;
/** Katalog aller Achievements (Titel -> Beschreibung), in Anzeige-Reihenfolge. */
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
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.model.GameScore;
import com.example.mindflux.repository.GameScoreRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@@ -16,10 +18,14 @@ import java.time.LocalDateTime;
@Service
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.achievementService = achievementService;
}
/**
@@ -66,9 +72,33 @@ public class GameScoreService {
int highscore = previousBest == null ? score : Math.max(previousBest.getScore(), score);
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);
}
/** 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. */
public GameStats getStats(String username, String 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 java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -19,11 +20,12 @@ public class ProgressService {
static {
GAMES.put("mindmatch", "MindMatch");
GAMES.put("techtacular", "TechTacular");
GAMES.put("syntax-saga", "Syntax Saga");
GAMES.put("escape-room", "Code Escape Room");
GAMES.put("service-clash", "Service Clash");
GAMES.put("bug-hunter", "Bug Hunter");
GAMES.put("netzwerker", "Netzwerker");
GAMES.put("devlab", "DevLab");
GAMES.put("syntax-saga", "SyntaxSaga");
GAMES.put("codeescape", "CodeEscape");
GAMES.put("serviceclash", "ServiceClash");
GAMES.put("bughunter", "BugHunter");
GAMES.put("networker", "NetWorker");
}
private final GameScoreService gameScoreService;
@@ -32,17 +34,53 @@ public class ProgressService {
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) {
// 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();
data.setCorrectAnswers(432);
data.setCurrentXp(2400);
data.setXpForNextLevel(3000);
data.setUserLevel(5);
data.setProgressPercent(78);
// Näherung: moves = je nach Spiel gelöste Aufgaben bzw. richtige Antworten
// (bei MindMatch sind es Zug-Versuche – bewusst in Kauf genommen).
data.setCorrectAnswers((int) Math.min(gameScoreService.totalMoves(username), Integer.MAX_VALUE));
data.setCurrentXp((int) Math.min(totalXp, Integer.MAX_VALUE));
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;
}
/**
* 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). */
public List<GameProgress> getGameProgress(String username) {
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) {
Set<Long> excluded = (exclude == null) ? Set.of() : exclude;
List<TechtacularCategory> usable = categoryRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()
.stream().filter(c -> !c.getQuestions().isEmpty()).collect(Collectors.toList());
// 2-Query-Muster statt N+1: Kategorien+Fragen in einem Fetch-Join, danach alle
// Antwort-Collections in einer zweiten Query im selben Persistence Context laden.
List<TechtacularCategory> usable = new ArrayList<>(categoryRepository.findActiveWithQuestions());
questionRepository.findActiveWithAnswers();
Collections.shuffle(usable, random);
List<TechtacularCategory> chosen = usable.stream().limit(BOARD_CATEGORIES).toList();
@@ -128,12 +130,14 @@ public class TechtacularService {
// ---------- 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)
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()
.map(c -> new AdminCategoryRow(c.getId(), c.getSlug(), c.getTitle(), c.isActive(),
questionRepository.countByCategoryId(c.getId())))
counts.getOrDefault(c.getId(), 0L)))
.toList();
}
@@ -2,9 +2,34 @@ package com.example.mindflux.service;
import com.example.mindflux.model.User;
import java.util.List;
import java.util.Optional;
public interface UserService {
User findByUsername(String username);
User findByUsernameIgnoreCase(String username);
List<User> findAll();
Optional<User> findById(Long id);
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.GameScoreRepository;
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.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
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
private static final int MIN_PASSWORD_LENGTH = 8;
private static final Set<String> KNOWN_ROLES = Set.of("USER", "ADMIN");
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
@Autowired
private GameScoreRepository gameScoreRepository;
private final UserRepository userRepository;
private final GameScoreRepository gameScoreRepository;
private final AchievementRepository achievementRepository;
private final BCryptPasswordEncoder passwordEncoder;
@Autowired
private AchievementRepository achievementRepository;
public UserServiceImpl(UserRepository userRepository,
GameScoreRepository gameScoreRepository,
AchievementRepository achievementRepository,
BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.gameScoreRepository = gameScoreRepository;
this.achievementRepository = achievementRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public User findByUsername(String username) {
return userRepository.findByUsername(username).orElse(null); // <-- Optional aufgelöst
return userRepository.findByUsername(username).orElse(null);
}
@Override
@@ -30,19 +51,100 @@ public class UserServiceImpl implements UserService {
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
public void save(User 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
@Transactional
public void deleteByUsername(String username) {
// Zugehörige Daten mitlöschen (kein FK/Cascade -> manuell), damit keine
// verwaisten Datensätze zurückbleiben und ein neuer gleichnamiger Account
// keine Alt-Daten erbt.
// Defensive Mitlöschung – seit V10 sichern zusätzlich FK-Kaskaden in der DB ab.
gameScoreRepository.deleteByUsername(username);
achievementRepository.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;} }
/* ===== 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) {}
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)
function initMobileNav() {
@@ -30,12 +30,14 @@
<div class="mf-container">
<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>
<span class="mf-eyebrow">Deine Trophäen</span>
<h1 style="color:var(--heading); font-size:1.9rem; font-weight:800; margin:.3rem 0 .25rem;">🏆 Deine Trophäen</h1>
<span class="mf-eyebrow">Deine Erfolge</span>
<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>
<nav th:replace="~{fragments/layout :: profileTabs('erfolge')}"></nav>
<div class="mf-panel">
<div class="mf-ach-grid">
<div class="achievement-card"
+11 -69
View File
@@ -35,68 +35,8 @@
<body>
<div class="mf-shell">
<!-- ===== Sidebar ===== -->
<aside class="mf-sidebar">
<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>
<!-- ===== Sidebar (gemeinsames Fragment) ===== -->
<aside th:replace="~{fragments/layout :: sidebar('dashboard')}"></aside>
<!-- ===== 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>
<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>
<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>
</header>
@@ -186,12 +128,12 @@
const GAMES = [
['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'],
['Dev Lab','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'],
['Code Escape Room','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'],
['Bug Hunter','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'],
['DevLab','lab','Das Entwicklerlabor bündelt Java, SQL, CSS, PHP, HTML, Git, REST und Regex.','/devlab'],
['SyntaxSaga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'],
['CodeEscape','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'],
['ServiceClash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'],
['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/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']
];
document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>`
@@ -18,7 +18,9 @@
</div>
<div class="dlg-header-tools">
<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>
</header>
+4 -2
View File
@@ -41,12 +41,14 @@
<header class="mf-topbar dl-topbar">
<div class="mf-hello">
<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>
</div>
<div class="mf-tools">
<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>
</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>
</div>
<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>
<h2>Grundlagen</h2>
<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/win_win_strategie.html">Win-Win-Strategie</a>
</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>
</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.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">
<div class="mf-field">
@@ -71,7 +73,7 @@
</div>
<div class="mf-field">
<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 class="mf-field">
<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>
<div class="mm-header-tools">
<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>
</header>
+1 -11
View File
@@ -7,10 +7,6 @@
<link rel="stylesheet" href="/css/theme.css">
<script src="/js/theme.js"></script>
<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 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; }
@@ -32,13 +28,7 @@
<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>
<nav class="mf-tab-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>
<nav th:replace="~{fragments/layout :: profileTabs('profil')}"></nav>
<!-- Erfolgsmeldung -->
<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-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>
<span class="mf-eyebrow">Dein Fortschritt</span>
<h1 style="color:var(--heading); font-size:2.1rem; font-weight:800; margin:.3rem 0 .25rem;">Dein Fortschritt</h1>
<span class="mf-eyebrow">Deine Statistiken</span>
<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>
<nav th:replace="~{fragments/layout :: profileTabs('statistiken')}"></nav>
<!-- Gesamtfortschritt -->
<section class="mf-panel accent" style="margin-bottom:1.2rem;">
<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-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>
<span class="mf-eyebrow">Einstellungen</span>
<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>
<nav th:replace="~{fragments/layout :: profileTabs('einstellungen')}"></nav>
<!-- Flash-Meldungen (z. B. nach Passwortänderung) -->
<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>
</div>
<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>
<!-- Theme-Einstellung (Dummy) -->
<!-- Theme-Einstellung (an theme.js gekoppelt, wird in localStorage gespeichert) -->
<section class="mf-panel" style="margin-bottom:1.2rem;">
<h2 class="mf-section-title">Theme wechseln</h2>
<div class="mf-field">
<label class="mf-label">Modus auswählen:</label>
<select class="mf-select">
<option>Standard (Dark)</option>
<option>Hell (Light)</option>
<label class="mf-label" for="theme-select">Modus auswählen:</label>
<select class="mf-select" id="theme-select">
<option value="dark">Standard (Dark)</option>
<option value="light">Hell (Light)</option>
</select>
</div>
<small class="mf-hint">Hinweis: Nutze den Umschalter oben rechts, um zwischen Dark (Grün) und Light (Magenta) zu wechseln.</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>
<small class="mf-hint">Die Auswahl gilt sofort und wird auf diesem Gerät gespeichert.</small>
</section>
<!-- Passwort ändern -->
@@ -88,6 +71,18 @@
</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">
<a href="#">Support</a>
<a href="#">Feedback</a>
+4 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<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">
@@ -21,9 +21,11 @@
<div class="mf-container">
<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>
<nav th:replace="~{fragments/layout :: profileTabs('soziales')}"></nav>
<div class="mf-panel accent mf-soziales">
<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>
+4 -2
View File
@@ -13,10 +13,12 @@
<div class="ss-shell">
<header class="ss-header">
<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">
<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>
</header>
@@ -16,7 +16,9 @@
<h1 class="tt-title">TechTacular</h1>
<div class="tt-header-tools">
<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>
</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>