Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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")
Expand All @@ -48,6 +59,8 @@
public class UserController {
private final UserManagementService userService;

private final UserContext uContext;

private final RegistrationService regService;

private final ForgotPasswordService pwdService;
Expand All @@ -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<String> registerUser(@Valid @RequestBody SignupRequest signUpRequest) {
User user;
Expand Down Expand Up @@ -154,4 +173,57 @@ public ResponseEntity<String> 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<Resource> 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);
}
}
}
3 changes: 3 additions & 0 deletions server/src/main/java/dev/findfirst/users/model/user/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ public User(SignupRequest signup, String encodedPasswd) {
@Column("role_role_id")
private AggregateReference<Role, Integer> role;

@Column("user_photo")
private String userPhoto;

}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion server/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN user_photo varchar(255);
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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<User> 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());
}
}