diff --git a/docker-compose.yml b/docker-compose.yml index b6ce6cea..522295a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,8 +17,10 @@ services: - SPRING_MAIL_HOST=mail - SCREENSHOT_SERVICE_URL=${SCREENSHOT_SERVICE_URL} - FINDFIRST_SCREENSHOT_LOCATION=/app/screenshots + - FINDFIRST_UPLOAD_LOCATION=/app/profile-pictures/ volumes: - ./data/screenshots:/app/screenshots + - ./data/uploads/profile-pictures:/app/profile-pictures screenshot: image: ghcr.io/r-sandor/findfirst-screenshot:latest ports: diff --git a/server/src/main/java/dev/findfirst/users/controller/UserController.java b/server/src/main/java/dev/findfirst/users/controller/UserController.java index caf2e601..c00edf23 100644 --- a/server/src/main/java/dev/findfirst/users/controller/UserController.java +++ b/server/src/main/java/dev/findfirst/users/controller/UserController.java @@ -1,14 +1,20 @@ package dev.findfirst.users.controller; +import java.io.File; +import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.Files; import java.rmi.UnexpectedException; +import java.util.Arrays; +import java.util.Optional; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import dev.findfirst.security.jwt.exceptions.TokenRefreshException; import dev.findfirst.security.jwt.service.RefreshTokenService; +import dev.findfirst.security.userauth.context.UserContext; import dev.findfirst.security.userauth.models.RefreshToken; import dev.findfirst.security.userauth.models.TokenRefreshResponse; import dev.findfirst.security.userauth.models.payload.request.SignupRequest; @@ -29,8 +35,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -40,6 +49,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/user") @@ -48,6 +59,8 @@ public class UserController { private final UserManagementService userService; + private final UserContext uContext; + private final RegistrationService regService; private final ForgotPasswordService pwdService; @@ -60,6 +73,12 @@ public class UserController { @Value("${findfirst.app.domain}") private String domain; + @Value("${findfirst.upload.max-file-size}") + private int maxFileSize; + + @Value("${findfirst.upload.allowed-types}") + private String[] allowedTypes; + @PostMapping("/signup") public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) { User user; @@ -154,4 +173,57 @@ public ResponseEntity refreshToken( return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(token); }).orElseThrow(() -> new TokenRefreshException(jwt, "Refresh token is not in database!")); } + + @PostMapping("/profile-picture") + public ResponseEntity uploadProfilePicture(@RequestParam("file") @Size(max = maxFileSize) MultipartFile file) { + + // File type validation + String contentType = file.getContentType(); + if (Arrays.stream(allowedTypes).noneMatch(contentType::equals)) { + return ResponseEntity.badRequest().body("Invalid file type. Only JPG and PNG are allowed."); + } + + try { + User user = userService.getUserById(uContext.getUserId()).orElseThrow(NoUserFoundException::new); + userService.changeUserPhoto(user, file); + + return ResponseEntity.ok("File uploaded successfully."); + } catch (NoUserFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found."); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to upload file."); + } + } + + @GetMapping("/profile-picture") + public ResponseEntity getUserProfilePicture(@RequestParam("userId") int userId) { + try { + User user; + try { + user = userService.getUserById(userId).orElseThrow(() -> new NoUserFoundException()); + } catch (NoUserFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + } + String userPhotoPath = user.getUserPhoto(); + + if (userPhotoPath == null || userPhotoPath.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + } + + // Load file + File photoFile = new File(userPhotoPath); + if (!photoFile.exists()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + } + + // Create response + Resource fileResource = new FileSystemResource(photoFile); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(Files.probeContentType(photoFile.toPath()))) + .body(fileResource); + + } catch (IOException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); + } + } } diff --git a/server/src/main/java/dev/findfirst/users/model/user/User.java b/server/src/main/java/dev/findfirst/users/model/user/User.java index 06f3a042..2f2b2f08 100644 --- a/server/src/main/java/dev/findfirst/users/model/user/User.java +++ b/server/src/main/java/dev/findfirst/users/model/user/User.java @@ -43,4 +43,7 @@ public User(SignupRequest signup, String encodedPasswd) { @Column("role_role_id") private AggregateReference role; + @Column("user_photo") + private String userPhoto; + } diff --git a/server/src/main/java/dev/findfirst/users/service/UserManagementService.java b/server/src/main/java/dev/findfirst/users/service/UserManagementService.java index 9199533a..e9fb7a74 100644 --- a/server/src/main/java/dev/findfirst/users/service/UserManagementService.java +++ b/server/src/main/java/dev/findfirst/users/service/UserManagementService.java @@ -1,5 +1,6 @@ package dev.findfirst.users.service; +import java.io.File; import java.nio.charset.StandardCharsets; import java.rmi.UnexpectedException; import java.time.Instant; @@ -32,6 +33,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -48,6 +50,9 @@ public class UserManagementService { @Value("${findfirst.app.jwtExpirationMs}") private int jwtExpirationMs; + @Value("${findfirst.upload.location}") + private String uploadLocation; + public User getUserByEmail(String email) throws NoUserFoundException { return userRepo.findByEmail(email).orElseThrow(NoUserFoundException::new); } @@ -81,6 +86,41 @@ public void changeUserPassword(User user, String password) { saveUser(user); } + public void changeUserPhoto(User user, MultipartFile file) { + + // Create directory for uploads + File uploadDir = new File(uploadLocation); + if (!uploadDir.exists()) { + uploadDir.mkdirs(); + } + + // Delete old photo (if exists) + removeUserPhoto(user); + + // Save new photo + String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + File destinationFile = new File(uploadLocation + fileName); + file.transferTo(destinationFile); + String userPhoto = uploadLocation + fileName; + + log.info("Changing profile picture for user ID {}: {}", user.getUserId(), userPhoto); + user.setUserPhoto(userPhoto); + saveUser(user); + } + + public void removeUserPhoto(User user) { + String userPhoto = user.getUserPhoto(); + if (userPhoto != null) { + File photoFile = new File(userPhoto); + if (photoFile.exists()) { + log.info("Removing profile picture for user ID {}: {}", user.getUserId(), userPhoto); + photoFile.delete(); + user.setUserPhoto(null); + saveUser(user); + } + } + } + public String createVerificationToken(User user) { String token = UUID.randomUUID().toString(); Token verificationToken = new Token(AggregateReference.to(user.getUserId()), token); diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties index 167dd636..0b739f8b 100644 --- a/server/src/main/resources/application.properties +++ b/server/src/main/resources/application.properties @@ -2,7 +2,7 @@ # Profiles run next. # Set active profiles for the application. -spring.profiles.active=dev +spring.profiles.active=dev # SQL Related properties. spring.sql.init.continue-on-error=false @@ -22,11 +22,17 @@ findfirst.screenshot.location=${FINDFIRST_SCREENSHOT_LOCATION:${findfirst.local. findfirst.app.frontend-url=${FINDFIRST_APP_FRONTEND-URL:http://localhost:3000/} findfirst.app.domain=localhost +findfirst.upload.allowed-types=image/jpeg,image/png +findfirst.local.upload.profile-pictures=../data/uploads/profile-pictures/ +findfirst.upload.location=${FINDFIRST_UPLOAD_LOCATION:${findfirst.local.upload.profile-pictures}} + # ERROR HANDLING # https://docs.spring.io/spring-boot/api/java/org/springframework/boot/autoconfigure/web/ErrorProperties.IncludeStacktrace.html server.error.include-stacktrace=never # Maximum size of a single uploaded file +# 2 MB in bytes +findfirst.upload.max-file-size=2097152 ############################################ # Multipart File Settings diff --git a/server/src/main/resources/db/migration/V1.0.011_add_userPhoto_to_users.sql b/server/src/main/resources/db/migration/V1.0.011_add_userPhoto_to_users.sql new file mode 100644 index 00000000..84f72561 --- /dev/null +++ b/server/src/main/resources/db/migration/V1.0.011_add_userPhoto_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN user_photo varchar(255); diff --git a/server/src/test/java/dev/findfirst/users/controller/UserControllerTest.java b/server/src/test/java/dev/findfirst/users/controller/UserControllerTest.java index f3ab7025..ef414fb0 100644 --- a/server/src/test/java/dev/findfirst/users/controller/UserControllerTest.java +++ b/server/src/test/java/dev/findfirst/users/controller/UserControllerTest.java @@ -2,7 +2,9 @@ import static org.junit.Assert.assertNotNull; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; import java.util.Optional; import java.util.Properties; @@ -12,10 +14,16 @@ import dev.findfirst.security.userauth.models.payload.request.SignupRequest; import dev.findfirst.users.model.MailHogMessage; import dev.findfirst.users.model.user.TokenPassword; +import dev.findfirst.users.model.user.User; +import dev.findfirst.users.service.UserManagementService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; @@ -26,8 +34,10 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.TestPropertySource; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.PostgreSQLContainer; @@ -41,7 +51,17 @@ @TestPropertySource(locations = "classpath:application-test.yml") class UserControllerTest { - final TestRestTemplate restTemplate; + TestRestTemplate restTemplate = new TestRestTemplate(); + + @Mock + private UserManagementService userManagementService; + + @InjectMocks + private UserController userController; + + public UserControllerTest() { + MockitoAnnotations.openMocks(this); + } @Autowired UserControllerTest(TestRestTemplate tRestTemplate) { @@ -181,4 +201,101 @@ void refreshToken() { HttpMethod.POST, new HttpEntity<>(new HttpHeaders()), String.class, refreshTkn); assertEquals(HttpStatus.OK, resp.getStatusCode()); } + + @Test + void testUploadProfilePicture_Success() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "test.jpg", "image/jpeg", "dummy content".getBytes()); + int userId = 1; + + User user = new User(); + user.setUserId(userId); + user.setUsername("testUser"); + when(userManagementService.getUserById(userId)).thenReturn(Optional.of(user)); + + ResponseEntity response = userController.uploadProfilePicture(file, userId); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("File uploaded successfully.", response.getBody()); + verify(userManagementService, times(1)).changeUserPhoto(eq(user), anyString()); + } + + @Test + void testRemoveUserPhoto_Success() { + User user = new User(); + user.setUserId(1); + user.setUsername("testUser"); + user.setUserPhoto("uploads/profile-pictures/test.jpg"); + + when(userManagementService.getUserById(user.getUserId())).thenReturn(Optional.of(user)); + + userManagementService.removeUserPhoto(user); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userManagementService, times(1)).saveUser(userCaptor.capture()); + assertNull(userCaptor.getValue().getUserPhoto()); + } + + @Test + void testUploadProfilePicture_FileSizeExceedsLimit() throws Exception { + byte[] largeContent = new byte[3 * 1024 * 1024]; // 3 MB + MockMultipartFile file = new MockMultipartFile("file", "large.jpg", "image/jpeg", largeContent); + int userId = 1; + + User user = new User(); + user.setUserId(userId); + user.setUsername("testUser"); + when(userManagementService.getUserById(userId)).thenReturn(Optional.of(user)); + + ResponseEntity response = userController.uploadProfilePicture(file, userId); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("File size exceeds the maximum limit of 2 MB.", response.getBody()); + } + + @Test + void testUploadProfilePicture_InvalidFileType() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "dummy content".getBytes()); + int userId = 1; + + User user = new User(); + user.setUserId(userId); + user.setUsername("testUser"); + when(userManagementService.getUserById(userId)).thenReturn(Optional.of(user)); + + ResponseEntity response = userController.uploadProfilePicture(file, userId); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("Invalid file type. Only JPG and PNG are allowed.", response.getBody()); + } + + @Test + void testGetUserProfilePicture_NotFound() { + int userId = 1; + + User user = new User(); + user.setUserId(userId); + user.setUsername("testUser"); + user.setUserPhoto(null); + when(userManagementService.getUserById(userId)).thenReturn(Optional.of(user)); + + ResponseEntity response = userController.getUserProfilePicture(userId); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + } + + @Test + void testGetUserProfilePicture_Success() { + int userId = 1; + + User user = new User(); + user.setUserId(userId); + user.setUsername("testUser"); + user.setUserPhoto("uploads/profile-pictures/test.jpg"); + when(userManagementService.getUserById(userId)).thenReturn(Optional.of(user)); + + ResponseEntity response = userController.getUserProfilePicture(userId); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + } }