diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml deleted file mode 100644 index b00f8e9..0000000 --- a/.github/workflows/github-actions-demo.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: GitHub Actions Demo -run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 -on: [push] -jobs: - Explore-GitHub-Actions: - runs-on: ubuntu-latest - steps: - - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." - - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" - - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." - - name: Check out repository code - uses: actions/checkout@v5 - - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - - run: echo "🖥️ The workflow is now ready to test your code on the runner." - - name: List files in the repository - run: | - ls ${{ github.workspace }} - - run: echo "🍏 This job's status is ${{ job.status }}." diff --git a/projekt/backend/pom.xml b/projekt/backend/pom.xml index e999b4b..0059c22 100644 --- a/projekt/backend/pom.xml +++ b/projekt/backend/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.0 + 4.0.4 com.projektSSE @@ -41,7 +41,7 @@ org.springframework.security spring-security-crypto - 7.0.0 + 7.0.4 @@ -81,14 +81,12 @@ org.springframework.boot spring-boot-starter-mail - 4.0.1 org.springframework.boot spring-boot-resttestclient - 4.0.0 test @@ -100,7 +98,7 @@ org.mockito mockito-core - 5.22.0 + 5.23.0 test @@ -108,7 +106,7 @@ org.slf4j slf4j-api - 2.1.0-alpha1 + 2.0.17 compile diff --git a/projekt/backend/src/main/java/com/projektsse/backend/config/CustomAccessDeniedHandler.java b/projekt/backend/src/main/java/com/projektsse/backend/config/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..69ece68 --- /dev/null +++ b/projekt/backend/src/main/java/com/projektsse/backend/config/CustomAccessDeniedHandler.java @@ -0,0 +1,38 @@ +package com.projektsse.backend.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public CustomAccessDeniedHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void handle(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull AccessDeniedException accessDeniedException) throws IOException { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "Forbidden"); + problemDetail.setDetail("You do not have permission to access this resource."); + problemDetail.setInstance(URI.create(request.getRequestURI())); + + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), problemDetail); + } +} diff --git a/projekt/backend/src/main/java/com/projektsse/backend/config/CustomAuthenticationEntryPoint.java b/projekt/backend/src/main/java/com/projektsse/backend/config/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..1fc1766 --- /dev/null +++ b/projekt/backend/src/main/java/com/projektsse/backend/config/CustomAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.projektsse.backend.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + public CustomAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + + @Override + public void commence(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull AuthenticationException authException) throws IOException { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Unauthorized"); + problemDetail.setDetail("Authentication is required to access this resource."); + problemDetail.setInstance(URI.create(request.getRequestURI())); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), problemDetail); + } +} diff --git a/projekt/backend/src/main/java/com/projektsse/backend/config/JwtFilter.java b/projekt/backend/src/main/java/com/projektsse/backend/config/JwtFilter.java index cf2944a..0c47704 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/config/JwtFilter.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/config/JwtFilter.java @@ -1,12 +1,14 @@ package com.projektsse.backend.config; import com.projektsse.backend.service.JwtService; +import jakarta.annotation.PostConstruct; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.NonNull; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; @@ -21,7 +23,9 @@ @Component public class JwtFilter extends OncePerRequestFilter { - private static final String FINGERPRINT_COOKIE_NAME = "__Secure-Fgp"; + @Value("${app.cookie.secure}") + private boolean cookieSecure; + private String FINGERPRINT_COOKIE_NAME; private final JwtService jwtService; private final UserDetailsService userDetailsService; @@ -31,6 +35,11 @@ public JwtFilter(JwtService jwtService, UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } + @PostConstruct + public void init() { + FINGERPRINT_COOKIE_NAME = cookieSecure ? "__Secure-Fgp" : "Fgp"; + } + @Override protected void doFilterInternal( HttpServletRequest request, @@ -99,6 +108,11 @@ protected boolean shouldNotFilter(HttpServletRequest request) { return path.startsWith("/api/auth/register") || path.startsWith("/api/auth/login") || path.startsWith("/api/auth/verify-email") || - path.startsWith("/api/documents/public"); + path.startsWith("/api/documents/public") || + path.startsWith("/api/auth/rt/refresh-token") || + path.startsWith("/api/auth/rt/logout") || + path.startsWith("/api/auth/forgot-password") || + path.startsWith("/api/auth/reset-password") || + path.startsWith("/api/auth/csrf"); } } diff --git a/projekt/backend/src/main/java/com/projektsse/backend/config/SecurityBeansConfig.java b/projekt/backend/src/main/java/com/projektsse/backend/config/SecurityBeansConfig.java index 1f510f9..c3aad8f 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/config/SecurityBeansConfig.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/config/SecurityBeansConfig.java @@ -27,6 +27,7 @@ public AuthenticationProvider authenticationProvider( public PasswordEncoder passwordEncoder() { // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id // Configurations taken from OWASP recommendations + return new Argon2PasswordEncoder( 16, // Salt length 32, // Hash length diff --git a/projekt/backend/src/main/java/com/projektsse/backend/config/SecurityConfig.java b/projekt/backend/src/main/java/com/projektsse/backend/config/SecurityConfig.java index 40d207c..ff167a8 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/config/SecurityConfig.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/config/SecurityConfig.java @@ -1,6 +1,6 @@ package com.projektsse.backend.config; -import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -15,17 +15,28 @@ @EnableWebSecurity public class SecurityConfig { + @Value("${app.cookie.secure}") + private boolean cookieSecure; + private final JwtFilter jwtFilter; - SecurityConfig(JwtFilter jwtFilter) { + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + SecurityConfig(JwtFilter jwtFilter, + CustomAuthenticationEntryPoint customAuthenticationEntryPoint, + CustomAccessDeniedHandler customAccessDeniedHandler + ) { this.jwtFilter = jwtFilter; + this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; + this.customAccessDeniedHandler = customAccessDeniedHandler; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) { CookieCsrfTokenRepository repo = CookieCsrfTokenRepository.withHttpOnlyFalse(); repo.setCookieCustomizer(cookie -> { - cookie.secure(true); + cookie.secure(cookieSecure); cookie.sameSite("Strict"); cookie.path("/"); }); @@ -38,6 +49,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { "/api/auth/register", "/api/auth/verify-email", "/api/documents/public", + "/api/documents/{docId}", + "/api/documents", "/api/documents/public/search", "/api/auth/forgot-password", "/api/auth/reset-password" @@ -49,8 +62,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .exceptionHandling(ex -> ex - .authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")) - .accessDeniedHandler((request, response, accessDeniedException) -> response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden")) + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) ) .authorizeHttpRequests(auth -> auth .requestMatchers( @@ -62,7 +75,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { "/api/auth/rt/refresh-token", "/api/auth/rt/logout", "/api/auth/forgot-password", - "/api/auth/reset-password" + "/api/auth/reset-password", + "/api/auth/csrf" ).permitAll().anyRequest().authenticated() ) .formLogin(AbstractHttpConfigurer::disable) diff --git a/projekt/backend/src/main/java/com/projektsse/backend/controller/AuthController.java b/projekt/backend/src/main/java/com/projektsse/backend/controller/AuthController.java index 7469f51..76dd4ef 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/controller/AuthController.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/controller/AuthController.java @@ -2,15 +2,15 @@ import com.projektsse.backend.controller.dto.*; import com.projektsse.backend.interfaces.CurrentUserId; -import com.projektsse.backend.models.UserReqModel; import com.projektsse.backend.service.JwtService; import com.projektsse.backend.service.PasswortResetService; import com.projektsse.backend.service.TokenService; import com.projektsse.backend.service.UserService; +import jakarta.annotation.PostConstruct; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; @@ -27,6 +27,10 @@ @Validated public class AuthController { + @Value("${app.cookie.secure}") + private boolean cookieSecure; + private String FINGERPRINT_COOKIE_NAME; + private final UserService userService; private final JwtService jwtService; private final TokenService tokenService; @@ -37,47 +41,36 @@ public AuthController(UserService userService, JwtService jwtService, TokenServi this.jwtService = jwtService; this.tokenService = tokenService; this.passwortResetService = passwortResetService; + } + @PostConstruct + private void init() { + this.FINGERPRINT_COOKIE_NAME = cookieSecure ? "__Secure-Fgp" : "Fgp"; // Runs after @Value injection } @PostMapping(value = "/register", consumes = "application/json", produces = "application/json") - public ResponseEntity register(@Validated @RequestBody RegisterReq req) { - - UserReqModel userReqModel = new UserReqModel(req.email(), req.password()); - - userService.registerUser(userReqModel); - - return ResponseEntity.status(HttpStatus.CREATED) - .body(Map.of( - "message", "Benutzer erfolgreich registriert. Bitte überprüfen Sie Ihre E-Mails zur Verifizierung." - )); - + public ResponseEntity register(@Validated @RequestBody RegisterRequest req) { + userService.registerUser(req.toModel()); + return ResponseEntity.status(HttpStatus.CREATED).body(new ApiMessage("User successfully registered. Please check your email for verification.")); } @GetMapping(value = "/verify-email", produces = "application/json") - public ResponseEntity verifyEmail( + public ResponseEntity verifyEmail( @RequestParam("code") - @NotBlank(message = "Ungültiger Verifizierungscode.") - @Size(min = 43, max = 43, message = "Ungültiger Verifizierungscode.") - @Pattern(regexp = "^[A-Za-z0-9_-]{43}$", message = "Ungültiger Verifizierungscode.") + @NotBlank(message = "Cannot be blank.") + @Pattern(regexp = "^[A-Za-z0-9_-]{43}$", message = "Invalid or expired link.") String code ) { - boolean isVerified = userService.verifyUserEmail(code); - if (isVerified) { - return ResponseEntity.ok(Map.of("message", "E-Mail erfolgreich verifiziert.")); - } else { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("message", "Ungültiger oder abgelaufener Verifizierungslink.")); - } + return ResponseEntity.ok(userService.verifyUserEmail(code).toDto()); } @PostMapping(value = "/login", consumes = "application/json", produces = "application/json") - public ResponseEntity login(@Validated @RequestBody LoginReq loginReq) { + public ResponseEntity login(@Validated @RequestBody LoginRequest loginRequest) { - userService.authenticateUser(loginReq.email(), loginReq.password()); + userService.authenticateUser(loginRequest.email(), loginRequest.password()); - String userId = userService.getUserIdByEmail(loginReq.email()).toString(); + String userId = userService.getUserIdByEmail(loginRequest.email()).toString(); // Fingerprint generieren String fingerprint = jwtService.generateFingerprint(); @@ -91,9 +84,9 @@ public ResponseEntity login(@Validated @RequestBody LoginReq loginReq) { Duration accessTokenDuration = Duration.ofMinutes(jwtService.getAccessTokenExpiration()); // Gleich wie JWT Expiry // Fingerprint Cookie (HttpOnly, Secure, SameSite=Strict) - ResponseCookie fingerprintCookie = ResponseCookie.from("__Secure-Fgp", fingerprint) + ResponseCookie fingerprintCookie = ResponseCookie.from(FINGERPRINT_COOKIE_NAME, fingerprint) .httpOnly(true) - .secure(true) + .secure(cookieSecure) .path("/") .maxAge(accessTokenDuration) // Max-Age <= JWT Expiry .sameSite("Strict") @@ -102,7 +95,7 @@ public ResponseEntity login(@Validated @RequestBody LoginReq loginReq) { // Refresh Token Cookie ResponseCookie refreshTokenCookie = ResponseCookie.from("REFRESH_TOKEN", refreshToken) .httpOnly(true) - .secure(true) + .secure(cookieSecure) .path("/api/auth/rt") .maxAge(durationDays) .sameSite("Strict") @@ -125,7 +118,7 @@ public ResponseEntity refreshToken(@CookieValue(name = "REFRESH_TOKEN") Strin if (userIdOpt.isEmpty()) { ResponseCookie deleteCookie = ResponseCookie.from("REFRESH_TOKEN", "") - .httpOnly(true).secure(true).path("/api/auth/rt") + .httpOnly(true).secure(cookieSecure).path("/api/auth/rt") .maxAge(0).sameSite("Strict").build(); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) @@ -142,14 +135,14 @@ public ResponseEntity refreshToken(@CookieValue(name = "REFRESH_TOKEN") Strin String newAccessToken = jwtService.generateAccessToken(userId, newFingerprintHash); String newRefreshToken = tokenService.rotateRefreshToken(refreshToken); - Duration accessTokenDuration = Duration.ofMinutes(10); + Duration accessTokenDuration = Duration.ofMinutes(jwtService.getAccessTokenExpiration()); - ResponseCookie fingerprintCookie = ResponseCookie.from("__Secure-Fgp", newFingerprint) - .httpOnly(true).secure(true).path("/") + ResponseCookie fingerprintCookie = ResponseCookie.from(FINGERPRINT_COOKIE_NAME, newFingerprint) + .httpOnly(true).secure(cookieSecure).path("/") .maxAge(accessTokenDuration).sameSite("Strict").build(); ResponseCookie refreshTokenCookie = ResponseCookie.from("REFRESH_TOKEN", newRefreshToken) - .httpOnly(true).secure(true).path("/api/auth/rt") + .httpOnly(true).secure(cookieSecure).path("/api/auth/rt") .maxAge(Duration.ofDays(7)).sameSite("Strict").build(); return ResponseEntity.ok() @@ -160,7 +153,7 @@ public ResponseEntity refreshToken(@CookieValue(name = "REFRESH_TOKEN") Strin @PostMapping(value = "/rt/logout", produces = "application/json") - public ResponseEntity logout( + public ResponseEntity logout( @CookieValue(name ="REFRESH_TOKEN", required = false) String refreshToken ) { if (refreshToken != null && !refreshToken.isBlank()) { @@ -169,15 +162,15 @@ public ResponseEntity logout( ResponseCookie deleteCookie = ResponseCookie.from("REFRESH_TOKEN", "") .httpOnly(true) - .secure(true) + .secure(cookieSecure) .path("/api/auth/rt") .maxAge(0) .sameSite("Strict") .build(); - ResponseCookie deleteFingerprintCookie = ResponseCookie.from("__Secure-Fgp", "") + ResponseCookie deleteFingerprintCookie = ResponseCookie.from(FINGERPRINT_COOKIE_NAME, "") .httpOnly(true) - .secure(true) + .secure(cookieSecure) .path("/") .maxAge(0) .sameSite("Strict") @@ -186,41 +179,42 @@ public ResponseEntity logout( return ResponseEntity.ok() .header("Set-Cookie", deleteCookie.toString()) .header("Set-Cookie", deleteFingerprintCookie.toString()) - .body(Map.of("message", "Erfolgreich abgemeldet.")); + .body(new ApiMessage("Successfully logged out.")); } @DeleteMapping(value = "/me", consumes = "application/json", produces = "application/json") - public ResponseEntity deleteAccount( + public ResponseEntity deleteAccount( @CurrentUserId UUID userId, @Valid @RequestBody DeleteAccountReq deleteAccountReq ) { userService.deleteUserAccount(userId, deleteAccountReq.password()); - - return ResponseEntity.ok() - .body(Map.of("message", "Benutzerkonto erfolgreich gelöscht.")); + return ResponseEntity.ok(new ApiMessage("Account successfully deleted.")); } @PostMapping(value = "/forgot-password", consumes = "application/json", produces = "application/json") - public ResponseEntity forgotPassword(@Validated @RequestBody EmailPasswordReset emailPasswordReset) { + public ResponseEntity forgotPassword(@Validated @RequestBody EmailPasswordReset emailPasswordReset) { passwortResetService.createPasswordReset(emailPasswordReset); - return ResponseEntity.ok(Map.of("message", "Wenn ein Konto mit dieser E-Mail-Adresse existiert, wurde eine E-Mail mit Anweisungen zum Zurücksetzen des Passworts gesendet")); + return ResponseEntity.ok(new ApiMessage("If an account with that email exists, a password reset link has been sent.")); } @GetMapping(value = "/reset-password", produces = "application/json") - public ResponseEntity handleResetLink(@RequestParam String token) { + public ResponseEntity handleResetLink(@RequestParam String token) { passwortResetService.validateToken(token); - return ResponseEntity.ok(Map.of("message", "Token ist gültig. Sie können nun ein neues Passwort festlegen.")); + return ResponseEntity.ok(new ApiMessage("Token is valid. You can proceed to reset your password.")); } @PostMapping(value = "/reset-password", consumes = "application/json", produces = "application/json") - public ResponseEntity verifyPasswordReset(@Validated @RequestBody PasswordResetRequest passwordResetReq) { + public ResponseEntity verifyPasswordReset(@Validated @RequestBody PasswordResetRequest passwordResetReq) { passwortResetService.verifyPasswordReset(passwordResetReq); - return ResponseEntity.ok(Map.of("message", "Passwort erfolgreich zurückgesetzt.")); + return ResponseEntity.ok(new ApiMessage("Password reset successful.")); } - + @GetMapping(value = "/csrf") + public ResponseEntity getCsrfToken() { + return ResponseEntity.ok().build(); + } } diff --git a/projekt/backend/src/main/java/com/projektsse/backend/controller/NotesController.java b/projekt/backend/src/main/java/com/projektsse/backend/controller/NotesController.java index 21dc939..e7c342c 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/controller/NotesController.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/controller/NotesController.java @@ -1,12 +1,14 @@ package com.projektsse.backend.controller; -import com.projektsse.backend.controller.dto.NoteReq; +import com.projektsse.backend.controller.dto.NoteRequest; +import com.projektsse.backend.controller.dto.NoteResponse; import com.projektsse.backend.interfaces.CurrentUserId; import com.projektsse.backend.models.NoteModel; import com.projektsse.backend.service.NoteService; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -15,8 +17,8 @@ import java.util.UUID; @RestController -@RequestMapping("/api/documents") @Validated +@RequestMapping("/api/documents") public class NotesController { NoteService noteService; @@ -26,61 +28,68 @@ public class NotesController { } @GetMapping(value = "/public", produces = "application/json") - public ResponseEntity getNotes() { - List notes = noteService.getAllPublicNotes(); + public ResponseEntity> getNotes() { + List notes = noteService.getAllPublicNotes().stream().map(NoteModel::toDto).toList(); return ResponseEntity.ok().body(notes); } @GetMapping(value = "/public/search", produces = "application/json") - public ResponseEntity searchPublicNotes( + public ResponseEntity> searchPublicNotes( @RequestParam("q") @NotBlank @Size(max = 50) - //@Pattern(regexp = "^[a-zA-Z0-9 äöüÄÖÜß!?.,-]*$", message = "Query contains invalid characters") String query ) { - List notes = noteService.searchPublicNotes(query); + List notes = noteService.searchPublicNotes(query).stream().map(NoteModel::toDto).toList(); return ResponseEntity.ok().body(notes); } @GetMapping(value = "/{documentId:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}", produces = "application/json") - public ResponseEntity getNoteById(@PathVariable UUID documentId) { - NoteModel note = noteService.getNoteById(documentId); + public ResponseEntity getNoteById(@PathVariable UUID documentId) { + NoteResponse note = noteService.getNoteById(documentId).toDto(); return ResponseEntity.ok().body(note); } @GetMapping(value = "/user", produces = "application/json") - public ResponseEntity getUserNotes(@CurrentUserId UUID userId) { - List notes = noteService.getNotesByUserId(userId); - return ResponseEntity.ok().body(notes); + public ResponseEntity> getUserNotes(@CurrentUserId UUID userId) { + return ResponseEntity.ok().body(noteService.getNotesByUserId(userId).stream().map(NoteModel::toDto).toList()); } @GetMapping(value = "/user/search", produces = "application/json") - public ResponseEntity searchUserNotes( + public ResponseEntity> searchUserNotes( @RequestParam("q") @NotBlank @Size(max = 50) - // @Pattern(regexp = "^[a-zA-Z0-9 äöüÄÖÜß!?.,-]*$", message = "Query contains invalid characters") String query, @CurrentUserId UUID userId ) { - List notes = noteService.searchUserNotes(query, userId); + List notes = noteService.searchUserNotes(query, userId).stream().map(NoteModel::toDto).toList(); return ResponseEntity.ok(notes); } @PostMapping(consumes = "application/json", produces = "application/json") - public ResponseEntity createNote(@Valid @RequestBody NoteReq noteReq, @CurrentUserId UUID userId) { - NoteModel note = noteService.createNote(noteReq, userId.toString()); - return ResponseEntity.status(201).body(note.noteId()); + public ResponseEntity createNote(@Valid @RequestBody NoteRequest noteRequest, @CurrentUserId UUID userId) { + NoteResponse note = noteService.createNote(noteRequest, userId.toString()).toDto(); + return ResponseEntity.status(HttpStatus.CREATED).body(note); } @DeleteMapping(value = "/{docId}", produces = "application/json") - public ResponseEntity deleteNote( + public ResponseEntity deleteNote( @PathVariable UUID docId, @CurrentUserId UUID userId ) { noteService.deleteNote(docId, userId); return ResponseEntity.noContent().build(); } + + @PutMapping(value = "/{docId}", consumes = "application/json") + public ResponseEntity updateNote( + @PathVariable UUID docId, + @Valid @RequestBody NoteRequest noteRequest, + @CurrentUserId UUID userId + ) { + NoteResponse updatedNote = noteService.updateNote(docId, noteRequest, userId).toDto(); + return ResponseEntity.ok().body(updatedNote); + } } diff --git a/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/ApiMessage.java b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/ApiMessage.java new file mode 100644 index 0000000..3363a7a --- /dev/null +++ b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/ApiMessage.java @@ -0,0 +1,8 @@ +package com.projektsse.backend.controller.dto; + +/** + * A simple DTO for API responses that contain a message. This can be used for various endpoints to return a standardized message format. + * @param message the message to be returned in the API response. + */ +public record ApiMessage(String message) { +} diff --git a/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/LoginReq.java b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/LoginRequest.java similarity index 94% rename from projekt/backend/src/main/java/com/projektsse/backend/controller/dto/LoginReq.java rename to projekt/backend/src/main/java/com/projektsse/backend/controller/dto/LoginRequest.java index 24a4722..376f6d4 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/LoginReq.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/LoginRequest.java @@ -13,11 +13,11 @@ * @param password no specific validation constraints are applied to the password field */ @GroupSequence({ - LoginReq.class, + LoginRequest.class, RegistrationValidationGroups.EmailSize.class, RegistrationValidationGroups.EmailFormat.class }) -public record LoginReq( +public record LoginRequest( @NotBlank(message = "Email cannot be empty") @Size(max = 255, message = "Email must be at most 255 characters long") @Email(message = "Invalid email format") diff --git a/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteReq.java b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteRequest.java similarity index 90% rename from projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteReq.java rename to projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteRequest.java index 9a2601e..e56d243 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteReq.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteRequest.java @@ -10,9 +10,9 @@ * @param mdContent Is the Markdown content of the note, must not be blank and has a maximum length of 10,000 characters. * @param isPrivate Indicates whether the note is private or public, must not be null. */ -public record NoteReq( +public record NoteRequest( @NotBlank(message = "Title cannot be empty") - @Size(max = 255, message = "Title can be at most 255 characters long") + @Size(max = 100, message = "Title can be at most 100 characters long") String title, @NotBlank(message = "Content cannot be empty") @Size(max = 10000, message = "Content can be at most 10,000 characters long") diff --git a/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteResponse.java b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteResponse.java new file mode 100644 index 0000000..5875dd8 --- /dev/null +++ b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/NoteResponse.java @@ -0,0 +1,15 @@ +package com.projektsse.backend.controller.dto; + + +import java.time.LocalDateTime; +import java.util.UUID; + +public record NoteResponse( + UUID noteId, + String title, + String md_content, + boolean is_private, + LocalDateTime created_at, + LocalDateTime updated_at, + UUID userId +) {} \ No newline at end of file diff --git a/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/RegisterReq.java b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/RegisterRequest.java similarity index 85% rename from projekt/backend/src/main/java/com/projektsse/backend/controller/dto/RegisterReq.java rename to projekt/backend/src/main/java/com/projektsse/backend/controller/dto/RegisterRequest.java index 08cf0c9..eabcdd2 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/RegisterReq.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/controller/dto/RegisterRequest.java @@ -2,6 +2,7 @@ import com.projektsse.backend.interfaces.RegistrationValidationGroups; import com.projektsse.backend.interfaces.StrongPasswordEmailCheck; +import com.projektsse.backend.models.UserReqModel; import jakarta.validation.GroupSequence; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -14,16 +15,20 @@ */ @StrongPasswordEmailCheck(groups = RegistrationValidationGroups.PasswordValidation.class) @GroupSequence({ - RegisterReq.class, + RegisterRequest.class, RegistrationValidationGroups.EmailSize.class, RegistrationValidationGroups.EmailFormat.class, RegistrationValidationGroups.PasswordValidation.class }) -public record RegisterReq( +public record RegisterRequest( @NotBlank(message = "Email must not be empty") @Size(max = 255, message = "Email must be at most 255 characters long", groups = RegistrationValidationGroups.EmailSize.class) @Email(message = "Invalid email format", groups = RegistrationValidationGroups.EmailFormat.class) String email, String password -) { } +) { + public UserReqModel toModel() { + return new UserReqModel(email, password); + } +} diff --git a/projekt/backend/src/main/java/com/projektsse/backend/exceptions/GlobalExceptionHandler.java b/projekt/backend/src/main/java/com/projektsse/backend/exceptions/GlobalExceptionHandler.java index 0d64caf..b92612b 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/exceptions/GlobalExceptionHandler.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/exceptions/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.projektsse.backend.exceptions; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import org.hibernate.StaleObjectStateException; @@ -14,7 +15,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import java.net.URI; -import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -47,16 +48,21 @@ public ProblemDetail handleIllegalState(IllegalStateException ex) { } @ExceptionHandler(MethodArgumentNotValidException.class) - public ProblemDetail handleValidationExceptions(MethodArgumentNotValidException ex) { + public ProblemDetail handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) { + log.warn("Validation error: {}", ex.getMessage()); ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); problemDetail.setTitle("Validation Error"); - problemDetail.setInstance(URI.create("/api/validation-error")); - Map errors = new HashMap<>(); - ex.getBindingResult().getFieldErrors().forEach(error -> { - String msg = error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid value"; - errors.put(error.getField(), msg); - }); - problemDetail.setProperties(errors); + problemDetail.setInstance(URI.create(request.getRequestURI())); + List> errors = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> { + assert fe.getDefaultMessage() != null; + return Map.of( + "field", fe.getField(), + "message", fe.getDefaultMessage() + ); + }) + .toList(); + problemDetail.setProperty("errors", errors); return problemDetail; } @@ -64,17 +70,22 @@ public ProblemDetail handleValidationExceptions(MethodArgumentNotValidException public ProblemDetail handleConstraintViolationException(ConstraintViolationException ex) { ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); problemDetail.setTitle("Validation Error"); - Map errors = ex.getConstraintViolations() - .stream() - .collect(Collectors.toMap( - violation -> violation.getPropertyPath().toString(), - ConstraintViolation::getMessage, - (existing, replacement) -> existing // In case of duplicate keys, keep the existing value - )); - problemDetail.setProperties(errors); + List> errors = ex.getConstraintViolations().stream() + .map(cv -> Map.of( + "field", extractFieldNameFromConstraintViolation(cv), + "message", cv.getMessage() + )) + .collect(Collectors.toList()); + problemDetail.setProperty("errors", errors); return problemDetail; } + private String extractFieldNameFromConstraintViolation(ConstraintViolation violation) { + String propertyPath = violation.getPropertyPath().toString(); + int lastDotIndex = propertyPath.lastIndexOf('.'); + return lastDotIndex != -1 ? propertyPath.substring(lastDotIndex + 1) : propertyPath; + } + @ExceptionHandler(NoteNotFoundException.class) public ProblemDetail handleNoteNotFoundException(NoteNotFoundException ex) { ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); @@ -111,6 +122,14 @@ public ProblemDetail handleErrorPWReset(WrongTokenException ex) { return problemDetail; } + @ExceptionHandler(VerificationFailedException.class) + public ProblemDetail handleInvalidLinkException(VerificationFailedException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + problemDetail.setTitle("Verification failed"); + problemDetail.setInstance(URI.create("/api/auth/verify-email")); + return problemDetail; + } + @ExceptionHandler(StaleObjectStateException.class) public ProblemDetail handleStaleObjectStateException() { diff --git a/projekt/backend/src/main/java/com/projektsse/backend/exceptions/VerificationFailedException.java b/projekt/backend/src/main/java/com/projektsse/backend/exceptions/VerificationFailedException.java new file mode 100644 index 0000000..325e914 --- /dev/null +++ b/projekt/backend/src/main/java/com/projektsse/backend/exceptions/VerificationFailedException.java @@ -0,0 +1,7 @@ +package com.projektsse.backend.exceptions; + +public class VerificationFailedException extends RuntimeException { + public VerificationFailedException(String message) { + super(message); + } +} diff --git a/projekt/backend/src/main/java/com/projektsse/backend/interfaces/StrongPasswordEmailCheckValidator.java b/projekt/backend/src/main/java/com/projektsse/backend/interfaces/StrongPasswordEmailCheckValidator.java index 6a57b3f..2480c18 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/interfaces/StrongPasswordEmailCheckValidator.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/interfaces/StrongPasswordEmailCheckValidator.java @@ -1,7 +1,7 @@ package com.projektsse.backend.interfaces; import com.nulabinc.zxcvbn.Zxcvbn; -import com.projektsse.backend.controller.dto.RegisterReq; +import com.projektsse.backend.controller.dto.RegisterRequest; import com.projektsse.backend.exceptions.WeakPasswordException; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; @@ -17,7 +17,7 @@ * The password must have a score of at least 4 to be considered strong. * Implementing this class as a ConstraintValidator allows us to use it as a custom validation annotation on the password field in our DTOs. */ -public class StrongPasswordEmailCheckValidator implements ConstraintValidator { +public class StrongPasswordEmailCheckValidator implements ConstraintValidator { /** * This method checks if the provided password meets the requirements of the password policy. @@ -27,7 +27,7 @@ public class StrongPasswordEmailCheckValidator implements ConstraintValidator= 255) { diff --git a/projekt/backend/src/main/java/com/projektsse/backend/models/ApiMessageModel.java b/projekt/backend/src/main/java/com/projektsse/backend/models/ApiMessageModel.java new file mode 100644 index 0000000..5e00ee2 --- /dev/null +++ b/projekt/backend/src/main/java/com/projektsse/backend/models/ApiMessageModel.java @@ -0,0 +1,10 @@ +package com.projektsse.backend.models; + +import com.projektsse.backend.controller.dto.ApiMessage; + +public record ApiMessageModel(String message) { + + public ApiMessage toDto() { + return new ApiMessage(message); + } +} diff --git a/projekt/backend/src/main/java/com/projektsse/backend/models/CustomUserDetails.java b/projekt/backend/src/main/java/com/projektsse/backend/models/CustomUserDetails.java index d5154b5..0c5b094 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/models/CustomUserDetails.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/models/CustomUserDetails.java @@ -8,12 +8,7 @@ import java.util.Collection; import java.util.Collections; -public class CustomUserDetails implements UserDetails { - private final User user; - - public CustomUserDetails(User user) { - this.user = user; - } +public record CustomUserDetails(User user) implements UserDetails { @Override @NullMarked @@ -53,8 +48,4 @@ public boolean isEnabled() { return true; } - public User getUser() { - return user; - } - } diff --git a/projekt/backend/src/main/java/com/projektsse/backend/models/NoteModel.java b/projekt/backend/src/main/java/com/projektsse/backend/models/NoteModel.java index 0bc37e8..77513f0 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/models/NoteModel.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/models/NoteModel.java @@ -1,5 +1,7 @@ package com.projektsse.backend.models; +import com.projektsse.backend.controller.dto.NoteResponse; + import java.time.LocalDateTime; import java.util.UUID; @@ -11,4 +13,21 @@ public record NoteModel( LocalDateTime created_at, LocalDateTime updated_at, UUID userId -) {} +) { + public NoteResponse toDto() { + return new NoteResponse(noteId, title, md_content, is_private, created_at, updated_at, userId); + } + + + public NoteModel setTitle(String title) { + return new NoteModel(noteId, title, md_content, is_private, created_at, updated_at, userId); + } + + public NoteModel setMdContent(String md_content) { + return new NoteModel(noteId, title, md_content, is_private, created_at, updated_at, userId); + } + + public NoteModel setIsPrivate(boolean is_private) { + return new NoteModel(noteId, title, md_content, is_private, created_at, updated_at, userId); + } +} diff --git a/projekt/backend/src/main/java/com/projektsse/backend/repository/NoteRepository.java b/projekt/backend/src/main/java/com/projektsse/backend/repository/NoteRepository.java index fed2709..e747e2c 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/repository/NoteRepository.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/repository/NoteRepository.java @@ -28,7 +28,4 @@ public interface NoteRepository extends CrudRepository { "LOWER(n.mdContent) LIKE LOWER(CONCAT('%', :query, '%')))") List searchUserNotes(@Param("userId") UUID userId, @Param("query") String query); -// @Query("SELECT n FROM Note n WHERE n.isPrivate = false") -// List findAllPublicNotes(); - } diff --git a/projekt/backend/src/main/java/com/projektsse/backend/repository/entities/Note.java b/projekt/backend/src/main/java/com/projektsse/backend/repository/entities/Note.java index 4f1889a..fab54d4 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/repository/entities/Note.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/repository/entities/Note.java @@ -2,9 +2,6 @@ import com.projektsse.backend.models.NoteModel; import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; @@ -29,7 +26,7 @@ public class Note { private boolean isPrivate; @CreationTimestamp - @Column(name = "created_at") + @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @UpdateTimestamp @@ -52,10 +49,6 @@ public void setOwner(User user) { this.user = user; } - public void setTitle(@NotBlank(message = "Titel darf nicht leer sein") @Size(max = 255, message = "Titel darf maximal 255 Zeichen lang sein") String title) { - this.title = title; - } - public NoteModel toModel() { return new NoteModel( this.noteId, @@ -68,12 +61,29 @@ public NoteModel toModel() { ); } - public void setMdContent(@NotBlank(message = "Inhalt darf nicht leer sein") @Size(max = 10000, message = "Inhalt darf maximal 10.000 Zeichen lang sein") String mdContent) { + public static Note fromModel(NoteModel model, User owner) { + Note note = new Note(); + note.setTitle(model.title()); + note.setMdContent(model.md_content()); + note.setIsPrivate(model.is_private()); + note.setOwner(owner); + return note; + } + + + public Note setTitle(String title) { + this.title = title; + return this; + } + + public Note setMdContent(String mdContent) { this.mdContent = mdContent; + return this; } - public void setIsPrivate(@NotNull(message = "Sichtbarkeit muss angegeben werden") boolean isPrivate) { + public Note setIsPrivate(boolean isPrivate) { this.isPrivate = isPrivate; + return this; } public User getOwner() { diff --git a/projekt/backend/src/main/java/com/projektsse/backend/service/CustomUserDetailsService.java b/projekt/backend/src/main/java/com/projektsse/backend/service/CustomUserDetailsService.java index 4e3d69e..c5e0284 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/service/CustomUserDetailsService.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/service/CustomUserDetailsService.java @@ -3,6 +3,7 @@ import com.projektsse.backend.models.CustomUserDetails; import com.projektsse.backend.repository.UserRepository; import com.projektsse.backend.repository.entities.User; +import org.jspecify.annotations.NullMarked; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -19,6 +20,7 @@ public CustomUserDetailsService(UserRepository userRepository) { } @Override + @NullMarked public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { User user = userRepository.findById(UUID.fromString(userId)) .orElseThrow(() -> new UsernameNotFoundException("User not found with id: " + userId)); diff --git a/projekt/backend/src/main/java/com/projektsse/backend/service/JwtService.java b/projekt/backend/src/main/java/com/projektsse/backend/service/JwtService.java index a4267e7..1f0b800 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/service/JwtService.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/service/JwtService.java @@ -3,7 +3,10 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.*; import com.auth0.jwt.interfaces.DecodedJWT; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; @@ -29,6 +32,8 @@ public class JwtService { @Value("${jwt.access-token-expiration}") private int accessTokenExpiration; // In Minuten + private final Logger log = LoggerFactory.getLogger(JwtService.class); + private Algorithm getAlgorithm() { byte[] keyBytes = Base64.getDecoder().decode(secret); return Algorithm.HMAC256(keyBytes); @@ -98,7 +103,20 @@ public boolean isTokenValid(String token, String fingerprintFromCookie, UserDeta String userId = decoded.getSubject(); boolean isExpired = decoded.getExpiresAt().before(new Date()); return userId.equals(userDetails.getUsername()) && !isExpired; - } catch (Exception e) { + } catch (AlgorithmMismatchException e) { + log.warn("Invalid JWT algorithm: {}", e.getMessage()); + return false; + } catch (SignatureVerificationException e) { + log.warn("Invalid JWT signature: {}", e.getMessage()); + return false; + } catch (TokenExpiredException e) { + log.warn("JWT token expired: {}", e.getMessage()); + return false; + } catch (MissingClaimException e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + return false; + } catch (IncorrectClaimException e) { + log.warn("Invalid JWT claim: {}", e.getMessage()); return false; } } diff --git a/projekt/backend/src/main/java/com/projektsse/backend/service/NoteService.java b/projekt/backend/src/main/java/com/projektsse/backend/service/NoteService.java index 58c3512..a2056c7 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/service/NoteService.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/service/NoteService.java @@ -1,7 +1,7 @@ package com.projektsse.backend.service; import com.projektsse.backend.config.MdSanitizer; -import com.projektsse.backend.controller.dto.NoteReq; +import com.projektsse.backend.controller.dto.NoteRequest; import com.projektsse.backend.exceptions.NoteNotFoundException; import com.projektsse.backend.models.NoteModel; import com.projektsse.backend.repository.NoteRepository; @@ -29,19 +29,18 @@ public NoteService(NoteRepository noteRepository, UserService userService, MdSan } public List getAllPublicNotes() { - List notes = noteRepository.findAllByIsPrivateFalse() + return noteRepository.findAllByIsPrivateFalse() .stream() .map(note -> ((Note) note).toModel()) .toList(); - return notes; } - public NoteModel createNote(@Valid NoteReq noteReq, String userId) { + public NoteModel createNote(@Valid NoteRequest noteRequest, String userId) { Note note = new Note(); - note.setTitle(mdSanitizer.sanitizeTitle(noteReq.title())); - note.setMdContent(mdSanitizer.sanitizeContent(noteReq.mdContent())); - note.setIsPrivate(noteReq.isPrivate()); + note.setTitle(mdSanitizer.sanitizeTitle(noteRequest.title())); + note.setMdContent(mdSanitizer.sanitizeContent(noteRequest.mdContent())); + note.setIsPrivate(noteRequest.isPrivate()); note.setOwner(userService.getUserById(userId)); Note savedNote = noteRepository.save(note); return savedNote.toModel(); @@ -49,15 +48,14 @@ public NoteModel createNote(@Valid NoteReq noteReq, String userId) { } public List searchPublicNotes(String query) { - String lowerCaseQuery = query.toLowerCase(); - List notes = noteRepository.searchPublicNotes(lowerCaseQuery) - .stream().map(note -> ((Note) note).toModel()).toList(); - return notes; + String lowerCaseQuery = query.toLowerCase().trim(); + return noteRepository.searchPublicNotes(lowerCaseQuery) + .stream().map(Note::toModel).toList(); } public NoteModel getNoteById(UUID documentId) { Note note = noteRepository.findById(documentId) - .orElseThrow(() -> new NoteNotFoundException("Notiz mit der ID " + documentId + " wurde nicht gefunden")); + .orElseThrow(() -> new NoteNotFoundException(String.format("Note with ID %s not found", documentId))); return note.toModel(); } @@ -76,14 +74,30 @@ public List searchUserNotes(@NotBlank @Size(max = 50) @Pattern(regexp public void deleteNote(UUID documentId, UUID userId) { Note note = noteRepository.findById(documentId) - .orElseThrow(() -> new NoteNotFoundException("Notiz mit der ID " + documentId + " wurde nicht gefunden")); + .orElseThrow(() -> new NoteNotFoundException(String.format("Note with ID %s not found", documentId))); - // Ist authorisiert? // Gleiche Exceptions um User Enumeration zu verhindern if (!note.getOwner().getId().equals(userId)) { - throw new NoteNotFoundException("Notiz mit der ID " + documentId + " wurde nicht gefunden"); + throw new NoteNotFoundException(String.format("Note with ID %s not found", documentId)); } noteRepository.delete(note); } + + public NoteModel updateNote(UUID docId, @Valid NoteRequest noteRequest, UUID userId) { + + Note noteEntity = noteRepository.findById(docId) + .orElseThrow(() -> new NoteNotFoundException(String.format("Note with ID %s not found", docId))); + + if (!noteEntity.getOwner().getId().equals(userId)) { + // Note exists, but belongs to another user, so we throw the same exception to prevent user enumeration + throw new NoteNotFoundException(String.format("Note with ID %s not found", docId)); + } + + noteEntity.setTitle(mdSanitizer.sanitizeTitle(noteRequest.title())) + .setMdContent(mdSanitizer.sanitizeContent(noteRequest.mdContent())) + .setIsPrivate(noteRequest.isPrivate()); + + return noteRepository.save(noteEntity).toModel(); + } } diff --git a/projekt/backend/src/main/java/com/projektsse/backend/service/TokenService.java b/projekt/backend/src/main/java/com/projektsse/backend/service/TokenService.java index 3243922..4c45320 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/service/TokenService.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/service/TokenService.java @@ -20,6 +20,9 @@ import static com.projektsse.backend.exceptions.GlobalExceptionHandler.log; +/** + * + */ @Service public class TokenService { diff --git a/projekt/backend/src/main/java/com/projektsse/backend/service/UserService.java b/projekt/backend/src/main/java/com/projektsse/backend/service/UserService.java index cc1d0f8..9442e0a 100644 --- a/projekt/backend/src/main/java/com/projektsse/backend/service/UserService.java +++ b/projekt/backend/src/main/java/com/projektsse/backend/service/UserService.java @@ -1,13 +1,14 @@ package com.projektsse.backend.service; +import com.projektsse.backend.exceptions.VerificationFailedException; import com.projektsse.backend.exceptions.UserNotFoundException; +import com.projektsse.backend.models.ApiMessageModel; import com.projektsse.backend.models.UserReqModel; import com.projektsse.backend.repository.RegistrationRepository; import com.projektsse.backend.repository.UserRepository; import com.projektsse.backend.repository.entities.Registration_Request; import com.projektsse.backend.repository.entities.User; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -26,6 +27,8 @@ public class UserService { private final EmailService emailService; private final RegistrationRepository registrationRepository; + private static final String DUMMY_HASH = "$argon2id$v=19$m=12288,t=3,p=1$bKJ65XHVvriCKaOG3i3WHw$Ruu7bnU7+IKhuSOAcYungNnDYbzILF5HEperRn5b28Q"; + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, TokenService tokenService, @@ -57,31 +60,21 @@ public void registerUser(UserReqModel userReqModel) { Registration_Request user = createPendingUser(userReqModel, verificationCode); registrationRepository.save(user); - // E-Mail versenden - String title = "E-Mail Verifizierung"; + // Sending mail + String title = "E-Mail verification"; String message = String.format(""" - Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken: + Please verify your email address by clicking the following link: http://localhost:8080/api/auth/verify-email?code=%s """, verificationCode); - if (existsByEmail(userReqModel.email())) { - title = "Hinweis auf ungewöhnliche Registrierungsaktivität"; - message = "Es wurde versucht, diese E-Mail-Adresse erneut zu registrieren. " + - "Wenn Sie das nicht waren, können Sie diese Nachricht ignorieren."; + title = "Suspicious registration attempt"; + message = "An attempt was made to register an account with this email address, but it is already in use. If this was not you, please ignore this email."; } - - emailService.sendMail( - user.getEmail(), - title, - message - ); - + emailService.sendMail(user.getEmail(), title, message); } - public boolean verifyUserEmail(@NotBlank(message = "Ungültiger Verifizierungscode.") @Size(min = 43, max = 43, message = "Ungültiger Verifizierungscode.") String code) { - - //Optional user = userRepository.findByVerificationCode(code); + public ApiMessageModel verifyUserEmail(String code) { Optional regReq = registrationRepository .findByVerificationCodeAndVerificationCodeExpiryAfter( tokenService.hashVerificationToken(code), @@ -89,10 +82,10 @@ public boolean verifyUserEmail(@NotBlank(message = "Ungültiger Verifizierungsco ); if (regReq.isEmpty()) { - return false; // Kein Benutzer mit diesem Code gefunden + throw new VerificationFailedException("Invalid or expired link."); // Kein Benutzer mit diesem Code gefunden } if (userRepository.existsByEmail(regReq.get().getEmail())) { - return false; // E-Mail ist bereits registriert + throw new VerificationFailedException("Invalid or expired link."); // E-Mail ist bereits registriert } User user = new User( @@ -102,8 +95,7 @@ public boolean verifyUserEmail(@NotBlank(message = "Ungültiger Verifizierungsco userRepository.save(user); registrationRepository.delete(regReq.get()); - return true; // Erfolgreich verifiziert - + return new ApiMessageModel("Email successfully verified."); } public UUID getUserIdByEmail(String email) { @@ -114,6 +106,7 @@ public UUID getUserIdByEmail(String email) { public void authenticateUser(String email, String password) { Optional userOpt = userRepository.findByEmail(email); if (userOpt.isEmpty()) { + passwordEncoder.matches(password, DUMMY_HASH); // Dummy-Hash to prevent timing attacks throw new IllegalArgumentException("Invalid credentials."); } User user = userOpt.get(); diff --git a/projekt/backend/src/main/resources/application-local.properties b/projekt/backend/src/main/resources/application-local.properties deleted file mode 100644 index ebe6060..0000000 --- a/projekt/backend/src/main/resources/application-local.properties +++ /dev/null @@ -1,17 +0,0 @@ -spring.datasource.url=jdbc:postgresql://localhost:5432/sse_local_db -spring.datasource.username=postgres -spring.datasource.password=zf5DDP3Ho# - -jwt.secret=0871b9b880f40f86a1b65ed54cca407af1b65ed54cca407af1bd7c66db877cb2529a68110df99809b368f2a4b1f67e70c7336dcf23a5f136138bdfb49ffea1e8f4883498d5252355 -jwt.access-token-expiration=10 - -refreshToken.value=CsSK3+JgE1C/mHVPmwBDfb1AAJ/bBObhnfqV0A1JIb+gV4NTuLXMqLoWroDsLAPcNqohdNBp0n065COODeBugQ== - -spring.mail.host=localhost -spring.mail.port=1025 -spring.mail.username= -spring.mail.password= -spring.mail.properties.mail.smtp.auth=false -spring.mail.properties.mail.smtp.starttls.enable=false -spring.mail.properties.mail.smtp.starttls.required=false -spring.mail.test-connection=false diff --git a/projekt/backend/src/main/resources/application.properties b/projekt/backend/src/main/resources/application.properties index edbb93f..f6fc1f6 100644 --- a/projekt/backend/src/main/resources/application.properties +++ b/projekt/backend/src/main/resources/application.properties @@ -25,7 +25,7 @@ jwt.access-token-expiration=10 refreshToken.value=${REFRESH_TOKEN_HMAC_SECRET} -# ini +# init spring.mail.host=${MAIL_HOST} spring.mail.port=${MAIL_PORT} spring.mail.username=${MAIL_USERNAME} @@ -35,5 +35,6 @@ spring.mail.properties.mail.smtp.starttls.enable=false spring.mail.properties.mail.smtp.starttls.required=false spring.mail.test-connection=false -# Test propeties +# Prod +app.cookie.secure=false diff --git a/projekt/backend/src/test/java/com/projektsse/backend/controller/Integration/Notes/NotesIntegrationTest.java b/projekt/backend/src/test/java/com/projektsse/backend/controller/Integration/Notes/NotesIntegrationTest.java new file mode 100644 index 0000000..851c938 --- /dev/null +++ b/projekt/backend/src/test/java/com/projektsse/backend/controller/Integration/Notes/NotesIntegrationTest.java @@ -0,0 +1,7 @@ +package com.projektsse.backend.controller.Integration.Notes; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class NotesIntegrationTest { +} diff --git a/projekt/backend/src/test/java/com/projektsse/backend/controller/Unit/Authentication/AuthControllerTest.java b/projekt/backend/src/test/java/com/projektsse/backend/controller/Unit/Authentication/AuthControllerTest.java index 0c40f2d..4a93e1d 100644 --- a/projekt/backend/src/test/java/com/projektsse/backend/controller/Unit/Authentication/AuthControllerTest.java +++ b/projekt/backend/src/test/java/com/projektsse/backend/controller/Unit/Authentication/AuthControllerTest.java @@ -1,7 +1,7 @@ package com.projektsse.backend.controller.Unit.Authentication; import com.projektsse.backend.controller.AuthController; -import com.projektsse.backend.controller.dto.RegisterReq; +import com.projektsse.backend.controller.dto.RegisterRequest; import com.projektsse.backend.exceptions.GlobalExceptionHandler; import com.projektsse.backend.service.JwtService; import com.projektsse.backend.service.PasswortResetService; @@ -15,6 +15,8 @@ import org.springframework.test.web.servlet.client.RestTestClient; import java.net.URI; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -43,11 +45,11 @@ void setUp() { void registerStrongPass() { client.post() .uri("/api/auth/register") - .body(new RegisterReq("frankestein@gmail.com", "Xfr@nke41!g+6&4")) + .body(new RegisterRequest("frankestein@gmail.com", "Xfr@nke41!g+6&4")) .exchange() .expectStatus().isCreated() .expectBody() - .jsonPath("$.message").isEqualTo("Benutzer erfolgreich registriert. Bitte überprüfen Sie Ihre E-Mails zur Verifizierung."); + .jsonPath("$.message").isEqualTo("User successfully registered. Please check your email for verification."); } @Test @@ -55,7 +57,7 @@ void registerWeakPass() { client.post() .uri("/api/auth/register") .accept(MediaType.APPLICATION_JSON) - .body(new RegisterReq("frankestein@gmail.com", "Password123")) + .body(new RegisterRequest("frankestein@gmail.com", "Password123")) .exchange() .expectStatus().isBadRequest() .expectBody(ProblemDetail.class) @@ -74,7 +76,7 @@ void registerWithEmailNull() { client.post() .uri("/api/auth/register") .accept(MediaType.APPLICATION_JSON) - .body(new RegisterReq(null, "Xfr@nke41!g+6&4")) + .body(new RegisterRequest(null, "Xfr@nke41!g+6&4")) .exchange() .expectStatus().isBadRequest() .expectBody(ProblemDetail.class) @@ -84,9 +86,11 @@ void registerWithEmailNull() { assertNotNull(problem.getProperties()); assertEquals("Validation Error", problem.getTitle()); assertEquals(400, problem.getStatus()); - assertEquals(URI.create("/api/validation-error"), problem.getInstance()); - assertTrue(problem.getProperties().containsKey("email")); - assertEquals("Email must not be empty", problem.getProperties().get("email")); + assertEquals(URI.create("/api/auth/register"), problem.getInstance()); + assertTrue(problem.getProperties().containsKey("errors")); + List> errors = (List>) problem.getProperties().get("errors"); + assertFalse(errors.isEmpty()); + assertTrue(errors.stream().anyMatch(error -> "email".equals(error.get("field")) && "Email must not be empty".equals(error.get("message")))); }); } @@ -95,7 +99,7 @@ void registerWithEmailEmpty() { client.post() .uri("/api/auth/register") .accept(MediaType.APPLICATION_JSON) - .body(new RegisterReq("", "Xfr@nke41!g+6&4")) + .body(new RegisterRequest("", "Xfr@nke41!g+6&4")) .exchange() .expectStatus().isBadRequest() .expectBody(ProblemDetail.class) @@ -105,9 +109,11 @@ void registerWithEmailEmpty() { assertNotNull(problem.getProperties()); assertEquals("Validation Error", problem.getTitle()); assertEquals(400, problem.getStatus()); - assertEquals(URI.create("/api/validation-error"), problem.getInstance()); - assertTrue(problem.getProperties().containsKey("email")); - assertEquals("Email must not be empty", problem.getProperties().get("email")); + assertEquals(URI.create("/api/auth/register"), problem.getInstance()); + assertTrue(problem.getProperties().containsKey("errors")); + List> errors = (List>) problem.getProperties().get("errors"); + assertFalse(errors.isEmpty()); + assertTrue(errors.stream().anyMatch(error -> "email".equals(error.get("field")) && "Email must not be empty".equals(error.get("message")))); }); } @@ -119,7 +125,7 @@ void registerEmailTooLong() { client.post() .uri("/api/auth/register") .accept(MediaType.APPLICATION_JSON) - .body(new RegisterReq(longEmail, "Xfr@nke41!g+6&4")) + .body(new RegisterRequest(longEmail, "Xfr@nke41!g+6&4")) .exchange() .expectStatus().isBadRequest() .expectBody(ProblemDetail.class) @@ -128,12 +134,11 @@ void registerEmailTooLong() { assertNotNull(problem.getProperties()); assertEquals("Validation Error", problem.getTitle()); assertEquals(400, problem.getStatus()); - assertEquals(URI.create("/api/validation-error"), problem.getInstance()); - assertTrue(problem.getProperties().containsKey("email")); - assertEquals( - "Email must be at most 255 characters long", - problem.getProperties().get("email") - ); + assertEquals(URI.create("/api/auth/register"), problem.getInstance()); + assertTrue(problem.getProperties().containsKey("errors")); + List> errors = (List>) problem.getProperties().get("errors"); + assertFalse(errors.isEmpty()); + assertTrue(errors.stream().anyMatch(error -> "email".equals(error.get("field")) && "Email must be at most 255 characters long".equals(error.get("message")))); }); } @@ -143,14 +148,14 @@ void passwordContainsEmailCredential() { client.post() .uri("/api/auth/register") .accept(MediaType.APPLICATION_JSON) - .body(new RegisterReq("maxmustermann@gmail.com", "MaxMustermann!Sec!")) + .body(new RegisterRequest("maxmustermann@gmail.com", "MaxMustermann!Sec!")) .exchange() .expectStatus().isBadRequest(); client.post() .uri("/api/auth/register") .accept(MediaType.APPLICATION_JSON) - .body(new RegisterReq("seline.schaefer@gmail.com", "MaxMustermann!Sec!")) + .body(new RegisterRequest("seline.schaefer@gmail.com", "MaxMustermann!Sec!")) .exchange() .expectStatus().isCreated(); } diff --git a/projekt/backend/src/test/java/com/projektsse/backend/service/NoteServiceTest.java b/projekt/backend/src/test/java/com/projektsse/backend/service/NoteServiceTest.java new file mode 100644 index 0000000..48ad7fe --- /dev/null +++ b/projekt/backend/src/test/java/com/projektsse/backend/service/NoteServiceTest.java @@ -0,0 +1,61 @@ +package com.projektsse.backend.service; + +import com.projektsse.backend.repository.NoteRepository; +import com.projektsse.backend.repository.UserRepository; +import com.projektsse.backend.repository.entities.User; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +class NoteServiceTest { + + @Autowired + private WebApplicationContext context; + + @Autowired + private NoteRepository noteRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtService jwtService; + + RestTestClient client; + + private User testUser; + private String authToken; + private String fingerprint; + private String fingerprintHash; + + @BeforeEach + void setUp(WebApplicationContext context) { + client = RestTestClient.bindToApplicationContext(context).build(); + + testUser = userRepository.save(new User("test@example.com", "$argon2id$v=19$m=16,t=2,p=1$T1ZsV1ZtV1ZVd1pRVG9uZw$Y2hpbGRyZW4xMjM0NTY3OA")); + + fingerprint = jwtService.generateFingerprint(); + fingerprintHash = jwtService.hashFingerprint(fingerprint); + authToken = jwtService.generateAccessToken(testUser.getId().toString(), fingerprintHash); + + } + +// @Test +// void testCreateNoteSuccess() { +// client.post() +// .uri("/api/documents") +// .header("Authorization", "Bearer " + authToken) +// .cookie("__Secure-Fgp", fingerprint) +// .cookie("X-XSRF-TOKEN", "null") +// .body(new NoteReq("Test Note", "This is a test note.", false)) +// .exchange() +// .expectStatus().isCreated(); +// } + +// @Test +// void updateNote() { +// } +} \ No newline at end of file diff --git a/projekt/docker-compose.dev.yml b/projekt/docker-compose.dev.yml index 7c08d49..8b3dd06 100644 --- a/projekt/docker-compose.dev.yml +++ b/projekt/docker-compose.dev.yml @@ -51,4 +51,6 @@ services: links: - "backend:backend" ports: - - "8080:8080" \ No newline at end of file + - "8080:8080" + volumes: + - ./frontend/src:/app/src \ No newline at end of file diff --git a/projekt/frontend/Dockerfile.dev b/projekt/frontend/Dockerfile.dev index 2466fd8..92fac3b 100644 --- a/projekt/frontend/Dockerfile.dev +++ b/projekt/frontend/Dockerfile.dev @@ -8,19 +8,24 @@ RUN npm install COPY . . -RUN npm run build +EXPOSE 8080 -FROM nginx:1.29.4-alpine +CMD ["npm", "run", "dev", "--", "--host", "--port", "8080"] -COPY nginx.dev.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html +# RUN npm run build -RUN touch /var/run/nginx.pid -RUN chown -R nginx:nginx /var/cache/nginx && \ - chown -R nginx:nginx /var/log/nginx && \ - chown -R nginx:nginx /etc/nginx/conf.d && \ - chown nginx:nginx /var/run/nginx.pid +# FROM nginx:1.29.4-alpine -USER nginx +# COPY nginx.dev.conf /etc/nginx/conf.d/default.conf +# COPY --from=build /app/dist /usr/share/nginx/html + +# RUN touch /var/run/nginx.pid +# RUN chown -R nginx:nginx /var/cache/nginx && \ +# chown -R nginx:nginx /var/log/nginx && \ +# chown -R nginx:nginx /etc/nginx/conf.d && \ +# chown nginx:nginx /var/run/nginx.pid + +# USER nginx + +# CMD ["nginx","-g","daemon off;"] -CMD ["nginx","-g","daemon off;"] \ No newline at end of file diff --git a/projekt/frontend/package-lock.json b/projekt/frontend/package-lock.json index c90034a..e53b384 100644 --- a/projekt/frontend/package-lock.json +++ b/projekt/frontend/package-lock.json @@ -8,6 +8,11 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-brands-svg-icons": "^7.2.0", + "@fortawesome/free-regular-svg-icons": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/react-fontawesome": "^3.2.0", "@tailwindcss/vite": "^4.2.1", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -1136,6 +1141,76 @@ } } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz", + "integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz", + "integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.2.0.tgz", + "integrity": "sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.2.0.tgz", + "integrity": "sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz", + "integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.2.0.tgz", + "integrity": "sha512-E9Gu1hqd6JussVO26EC4WqRZssXMnQr2ol7ZNWkkFOH8jZUaxDJ9Z9WF9wIVkC+kJGXUdY3tlffpDwEKfgQrQw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~6 || ~7", + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/projekt/frontend/package.json b/projekt/frontend/package.json index 640a4ba..97ab20b 100644 --- a/projekt/frontend/package.json +++ b/projekt/frontend/package.json @@ -12,6 +12,11 @@ "test:ci": "vitest run" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-brands-svg-icons": "^7.2.0", + "@fortawesome/free-regular-svg-icons": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/react-fontawesome": "^3.2.0", "@tailwindcss/vite": "^4.2.1", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", diff --git a/projekt/frontend/src/App.tsx b/projekt/frontend/src/App.tsx index 7fb993f..9417cf9 100644 --- a/projekt/frontend/src/App.tsx +++ b/projekt/frontend/src/App.tsx @@ -4,11 +4,13 @@ import RegisterPage from "./components/RegisterPage.tsx"; import AuthProvider from "./components/AuthProvider.tsx"; import {PublicDocumentsPage} from "./components/PublicDocumentsPage.tsx"; import DocumentDetailPage from "./components/DocumentDetailPage.tsx"; -import {CreateDocument} from "./components/CreateDocument.tsx"; import Navbar from "./components/Navbar.tsx"; import UserDocumentsPage from "./components/UserDocumentsPage.tsx"; import {PWResetEmail} from "./components/PWResetEmail.tsx"; import {PWResetPassword} from "./components/PWResetPassword.tsx"; +import {Profile} from "./components/Profile.tsx"; +import {UpdateDocument} from "./components/UpdateDocument.tsx"; +import {DocumentForm} from "./components/DocumentForm.tsx"; function App() { @@ -18,21 +20,22 @@ function App() { } /> } /> } /> - } /> } /> } /> - } /> + } /> + } /> } /> + } />
Main Page
} /> ); } - +// TODO: Add a main page with some welcome text and links to public documents, login, etc. export { App }; diff --git a/projekt/frontend/src/components/ApiErrorMessage.tsx b/projekt/frontend/src/components/ApiErrorMessage.tsx index df2daaa..4e0a34f 100644 --- a/projekt/frontend/src/components/ApiErrorMessage.tsx +++ b/projekt/frontend/src/components/ApiErrorMessage.tsx @@ -1,18 +1,29 @@ import React from 'react'; -import type { ErrorType } from '../types/ErrorType'; import '../styling/ErrorMessage.css'; +import {faTriangleExclamation} from "@fortawesome/free-solid-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import type {ApiErrorType} from "../types/ProblemDetail/ApiErrorType.ts"; +import {isDetailError, isValidationError} from "../types/ProblemDetail/IsErrorTypeGuards.ts"; -interface ApiErrorMessageProps { - error?: Partial; +interface Props { + error: ApiErrorType | undefined; } -const ApiErrorMessage: React.FC = ({ error }) => { - if (!error || !error.title) return null; +const ApiErrorMessage: React.FC = ({ error }: Props ) => { + if (!error) return null; return (
- ⚠️ {error.title} - {error.detail &&

{error.detail}

} + {error.title} + {isDetailError(error) &&

{error.detail}

} + + {isValidationError(error) && ( +
    + {error.errors.map((validationError, index) => ( +
  • {validationError.message}
  • + ))} +
+ )} {error.status && Status: {error.status}}
); diff --git a/projekt/frontend/src/components/AuthContext.tsx b/projekt/frontend/src/components/AuthContext.tsx index af14d61..b1abbe7 100644 --- a/projekt/frontend/src/components/AuthContext.tsx +++ b/projekt/frontend/src/components/AuthContext.tsx @@ -1,16 +1,11 @@ import {createContext} from 'react'; +import type {AuthContextType} from "../types/AuthContextType.ts"; -// Typen definieren -export interface AuthContextType { - token: string | null; - login: (newToken: string) => void; - logout: () => void; - refreshAccessToken: () => Promise; - isAuthenticated: boolean; -} - -// Store JWT-Token In-Memory +/** + * The AuthContext stores the authentication state of the user and provides methods for logging in and out. + * It is storing a JWT In-Memory, which is lost when the page is refreshed. + */ export const AuthContext = createContext(null); diff --git a/projekt/frontend/src/components/AuthProvider.tsx b/projekt/frontend/src/components/AuthProvider.tsx index ab700e3..22776e5 100644 --- a/projekt/frontend/src/components/AuthProvider.tsx +++ b/projekt/frontend/src/components/AuthProvider.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { getCookie } from "../utils/cookies"; import { AuthContext } from "./AuthContext"; +import LoadingBar from "./LoadingBar.tsx"; let refreshPromise: Promise | null = null; @@ -63,6 +64,7 @@ function AuthProvider({ children }: { children: React.ReactNode }) { refreshAccessToken().finally(() => setIsLoading(false)); }, [refreshAccessToken]); + // TODO: AuthContext.Provider ist deprecated. => Neuen Ansatz anschauen return ( - {isLoading ?
Lade...
: children} + {isLoading ? : children}
); } diff --git a/projekt/frontend/src/components/BackButton.tsx b/projekt/frontend/src/components/BackButton.tsx new file mode 100644 index 0000000..8967329 --- /dev/null +++ b/projekt/frontend/src/components/BackButton.tsx @@ -0,0 +1,19 @@ +import {useNavigate} from "react-router"; + +/** + * A simple back button component that either uses a provided onBack function or defaults to navigating back in history. + * @param onBack Optional callback function to execute when the back button is clicked. If not provided, it will navigate back using the browser history. + * @constructor + */ +function BackButton({onBack}: { onBack?: () => void }) { + const navigate = useNavigate(); + const handleBack = onBack ?? (() => navigate(-1)); + + return ( + + ); +} + +export default BackButton; \ No newline at end of file diff --git a/projekt/frontend/src/components/CreateDocument.tsx b/projekt/frontend/src/components/CreateDocument.tsx deleted file mode 100644 index 87ba306..0000000 --- a/projekt/frontend/src/components/CreateDocument.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useAuth } from '../utils/useAuth'; -import React, { useState } from 'react'; -import { useNavigate } from 'react-router'; -import { apiFetch } from '../utils/apiFetch'; -import ErrorMessage from './ErrorMessage'; -import Navbar from './Navbar'; -import '../styling/CreateDocuments.css'; - -interface FormData { - title: string; - mdContent: string; - isPrivate: boolean; -} - -interface Errors { - title?: string; - content?: string; - general?: string; -} - -function CreateDocument() { - const auth = useAuth(); - const navigate = useNavigate(); - - const [formData, setFormData] = useState({ - title: '', - mdContent: '', - isPrivate: false - }); - - const [errors, setErrors] = useState({}); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleChange = ( - e: React.ChangeEvent - ): void => { - const { name, value, type } = e.target; - - if (type === 'checkbox') { - setFormData(prev => ({ - ...prev, - [name]: (e.target as HTMLInputElement).checked - })); - } else { - setFormData(prev => ({ - ...prev, - [name]: value - })); - } - - if (errors[name as keyof Errors]) { - setErrors(prev => ({ - ...prev, - [name]: '' - })); - } - }; - - const handleSubmit = async (e: React.FormEvent): Promise => { - e.preventDefault(); - - if (!auth.isAuthenticated) { - setErrors({ general: 'Sie müssen angemeldet sein, um ein Dokument zu erstellen.' }); - return; - } - - const newErrors: Errors = {}; - if (!formData.title.trim()) { - newErrors.title = 'Titel ist erforderlich'; - } else if (formData.title.length > 200) { - newErrors.title = 'Titel darf maximal 200 Zeichen haben'; - } - - if (!formData.mdContent.trim()) { - newErrors.content = 'Inhalt ist erforderlich'; - } - - if (Object.keys(newErrors).length > 0) { - setErrors(newErrors); - return; - } - - setIsSubmitting(true); - setErrors({}); - - try { - const response = await apiFetch(auth, '/api/documents', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || 'Dokument konnte nicht erstellt werden'); - } - - const data = await response.json(); - navigate(data.noteId ? `/documents/${data.noteId}` : '/documents/public'); - - } catch (error) { - setErrors({ - general: error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten' - }); - } finally { - setIsSubmitting(false); - } - }; - - return ( - <> - -
-

Neues Dokument erstellen

- -
-
- - - -
- -
- -