diff --git a/src/main/java/com/example/mindflux/config/SecurityConfig.java b/src/main/java/com/example/mindflux/config/SecurityConfig.java index 7438707..6fa97c8 100644 --- a/src/main/java/com/example/mindflux/config/SecurityConfig.java +++ b/src/main/java/com/example/mindflux/config/SecurityConfig.java @@ -12,6 +12,7 @@ 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.header.writers.ReferrerPolicyHeaderWriter; @Configuration public class SecurityConfig { @@ -43,6 +44,17 @@ public class SecurityConfig { // Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen. // Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar. .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/serviceclash/**", "/api/networker/**", "/api/neuralnet/**") + ) + .headers(headers -> headers + // CSP: pragmatischer Start – alle Assets sind self-hosted (Chart.js lokal + // gevendort). 'unsafe-inline' bleibt vorerst nötig, weil Templates Inline- + // Styles und einzelne Inline-Skripte enthalten (später auf Nonces umstellbar). + .contentSecurityPolicy(csp -> csp.policyDirectives( + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")) + .referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + // HSTS bleibt auf Spring-Default: wird nur über HTTPS gesendet, greift also + // faktisch nur in Produktion (Dev läuft über HTTP). ); return http.build(); diff --git a/src/main/java/com/example/mindflux/controller/GameScoreController.java b/src/main/java/com/example/mindflux/controller/GameScoreController.java index 7b2d8a1..7ce0ef3 100644 --- a/src/main/java/com/example/mindflux/controller/GameScoreController.java +++ b/src/main/java/com/example/mindflux/controller/GameScoreController.java @@ -4,6 +4,7 @@ import com.example.mindflux.dto.GameStats; import com.example.mindflux.dto.ScoreResult; import com.example.mindflux.dto.ScoreSubmission; import com.example.mindflux.service.GameScoreService; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -32,7 +33,7 @@ public class GameScoreController { @PostMapping("/{game}/scores") public ResponseEntity submitScore(@PathVariable String game, - @RequestBody ScoreSubmission submission, + @Valid @RequestBody ScoreSubmission submission, Principal principal) { if (!KNOWN_GAMES.contains(game)) { return ResponseEntity.notFound().build(); diff --git a/src/main/java/com/example/mindflux/controller/ProfileController.java b/src/main/java/com/example/mindflux/controller/ProfileController.java index a07d76d..64767c2 100644 --- a/src/main/java/com/example/mindflux/controller/ProfileController.java +++ b/src/main/java/com/example/mindflux/controller/ProfileController.java @@ -17,19 +17,10 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.awt.Color; -import java.awt.Graphics2D; -import java.awt.RenderingHints; -import java.awt.image.BufferedImage; -import javax.imageio.ImageIO; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.security.Principal; -import java.util.UUID; import java.util.List; import com.example.mindflux.dto.WeeklyActivity; +import com.example.mindflux.service.AvatarService; @Controller public class ProfileController { @@ -45,7 +36,8 @@ public class ProfileController { @Autowired private ProgressService progressService; - private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads/profiles/"; + @Autowired + private AvatarService avatarService; @GetMapping("/profile") public String profile(Model model, Principal principal, @RequestParam(value = "success", required = false) Boolean success) { @@ -67,24 +59,8 @@ public class ProfileController { User existingUser = userService.findByUsername(username); if (existingUser != null) { - String profilePicture = existingUser.getProfilePicture(); - if (profilePicture != null && !profilePicture.trim().isEmpty()) { - try { - Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture); - if (Files.exists(picturePath)) { - Files.delete(picturePath); - log.info("Profilbild gelöscht: {}", picturePath.getFileName()); - } else { - log.warn("Profilbild-Datei existiert nicht: {}", picturePath.toAbsolutePath()); - } - } catch (IOException e) { - log.error("Fehler beim Löschen der Profilbild-Datei", e); - } - } else { - log.debug("Kein Profilbild gesetzt, nichts zu löschen."); - } - - existingUser.setProfilePicture(null); // Wichtig: auch wenn Datei nicht existiert + avatarService.delete(existingUser.getProfilePicture()); + existingUser.setProfilePicture(null); // auch wenn keine Datei existierte userService.save(existingUser); } @@ -122,47 +98,9 @@ public class ProfileController { if (existingUser != null) { existingUser.setEmail(email); // Email aktualisieren - if (file != null && !file.isEmpty()) { - try { - BufferedImage original = ImageIO.read(file.getInputStream()); - if (original == null) { - // Keine lesbare Bilddatei -> Upload ignorieren, restliches Profil trotzdem speichern. - log.warn("Profilbild-Upload ignoriert: keine lesbare Bilddatei."); - } else { - // Auf max. Kantenlänge herunterskalieren (kein Hochskalieren). - final int MAX_DIM = 400; - int w = original.getWidth(); - int h = original.getHeight(); - double scale = Math.min(1.0, (double) MAX_DIM / Math.max(w, h)); - int nw = Math.max(1, (int) Math.round(w * scale)); - int nh = Math.max(1, (int) Math.round(h * scale)); - - BufferedImage scaled = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB); - Graphics2D g = scaled.createGraphics(); - g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); - g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); - g.setColor(Color.WHITE); // JPEG kennt kein Alpha -> Transparenz auf weißem Grund - g.fillRect(0, 0, nw, nh); - g.drawImage(original, 0, 0, nw, nh, null); - g.dispose(); - - // Altes Profilbild löschen (falls vorhanden) - if (existingUser.getProfilePicture() != null && !existingUser.getProfilePicture().isEmpty()) { - Path oldFilePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", existingUser.getProfilePicture()); - Files.deleteIfExists(oldFilePath); - } - - // Normalisiert als JPEG speichern - String fileName = UUID.randomUUID() + ".jpg"; - Path uploadPath = Paths.get(UPLOAD_DIR); - Files.createDirectories(uploadPath); - ImageIO.write(scaled, "jpg", uploadPath.resolve(fileName).toFile()); - - existingUser.setProfilePicture(fileName); - } - } catch (IOException e) { - log.error("Fehler beim Speichern des Profilbilds", e); - } + String newAvatar = avatarService.store(file, existingUser.getProfilePicture()); + if (newAvatar != null) { + existingUser.setProfilePicture(newAvatar); } userService.save(existingUser); @@ -203,15 +141,7 @@ public class ProfileController { // Profilbild-Datei von der Platte entfernen (DB-Daten löscht der Service in einer Transaktion). User user = userService.findByUsername(username); if (user != null) { - String profilePicture = user.getProfilePicture(); - if (profilePicture != null && !profilePicture.trim().isEmpty()) { - try { - Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture); - Files.deleteIfExists(picturePath); - } catch (IOException e) { - log.error("Fehler beim Löschen des Profilbilds", e); - } - } + avatarService.delete(user.getProfilePicture()); } userService.deleteByUsername(username); diff --git a/src/main/java/com/example/mindflux/dto/ScoreSubmission.java b/src/main/java/com/example/mindflux/dto/ScoreSubmission.java index a01d339..4827e0b 100644 --- a/src/main/java/com/example/mindflux/dto/ScoreSubmission.java +++ b/src/main/java/com/example/mindflux/dto/ScoreSubmission.java @@ -1,14 +1,24 @@ package com.example.mindflux.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + /** * Was der Client nach einem abgeschlossenen Spiel sendet. Der Score wird * bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten * berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann. + * Die Constraints fangen offensichtlich unsinnige Eingaben schon vor der Logik ab. */ public class ScoreSubmission { + @NotBlank @Size(max = 100) private String deck; + @Min(0) @Max(1000) private int pairs; + @Min(0) @Max(100000) private int moves; + @Min(0) @Max(86400) private int durationSeconds; public String getDeck() { return deck; } diff --git a/src/main/java/com/example/mindflux/service/AvatarService.java b/src/main/java/com/example/mindflux/service/AvatarService.java new file mode 100644 index 0000000..d33efdf --- /dev/null +++ b/src/main/java/com/example/mindflux/service/AvatarService.java @@ -0,0 +1,99 @@ +package com.example.mindflux.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +import javax.imageio.ImageIO; + +/** + * Kapselt die gesamte Profilbild-Logik (Dekodierung, Skalierung, Datei-I/O, Pfad-Auflösung), + * die zuvor mehrfach im ProfileController dupliziert war. Der Controller bleibt so schlank + * und die Bildverarbeitung ist an einer Stelle testbar/änderbar. + */ +@Service +public class AvatarService { + + private static final Logger log = LoggerFactory.getLogger(AvatarService.class); + private static final int MAX_DIM = 400; + + /** Basisverzeichnis der Profilbilder – eine einzige Quelle der Wahrheit für den Pfad. */ + private Path baseDir() { + return Paths.get(System.getProperty("user.dir"), "uploads", "profiles"); + } + + /** Voller Pfad zu einem gespeicherten Profilbild. */ + public Path resolve(String fileName) { + return baseDir().resolve(fileName); + } + + /** + * Verarbeitet einen Bild-Upload: liest das Bild, skaliert auf max. Kantenlänge herunter, + * löscht ein evtl. vorhandenes altes Bild und speichert normalisiert als JPEG. + * + * @return der neue Dateiname, oder {@code null}, wenn kein/kein lesbares Bild geliefert wurde. + */ + public String store(MultipartFile file, String oldFileName) { + if (file == null || file.isEmpty()) { + return null; + } + try { + BufferedImage original = ImageIO.read(file.getInputStream()); + if (original == null) { + log.warn("Profilbild-Upload ignoriert: keine lesbare Bilddatei."); + return null; + } + int w = original.getWidth(); + int h = original.getHeight(); + double scale = Math.min(1.0, (double) MAX_DIM / Math.max(w, h)); + int nw = Math.max(1, (int) Math.round(w * scale)); + int nh = Math.max(1, (int) Math.round(h * scale)); + + BufferedImage scaled = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB); + Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.setColor(Color.WHITE); // JPEG kennt kein Alpha -> Transparenz auf weißem Grund + g.fillRect(0, 0, nw, nh); + g.drawImage(original, 0, 0, nw, nh, null); + g.dispose(); + + delete(oldFileName); // altes Bild aufräumen + + String fileName = UUID.randomUUID() + ".jpg"; + Path dir = baseDir(); + Files.createDirectories(dir); + ImageIO.write(scaled, "jpg", dir.resolve(fileName).toFile()); + return fileName; + } catch (IOException e) { + log.error("Fehler beim Speichern des Profilbilds", e); + return null; + } + } + + /** Löscht ein Profilbild, falls Name gesetzt und Datei vorhanden. Fehler werden nur geloggt. */ + public void delete(String fileName) { + if (fileName == null || fileName.trim().isEmpty()) { + return; + } + try { + Path path = resolve(fileName); + if (Files.deleteIfExists(path)) { + log.info("Profilbild gelöscht: {}", path.getFileName()); + } + } catch (IOException e) { + log.error("Fehler beim Löschen der Profilbild-Datei", e); + } + } +}