diff --git a/backend/src/main/java/vaultWeb/controllers/ChatImageController.java b/backend/src/main/java/vaultWeb/controllers/ChatImageController.java new file mode 100644 index 000000000..75d21a6c0 --- /dev/null +++ b/backend/src/main/java/vaultWeb/controllers/ChatImageController.java @@ -0,0 +1,148 @@ +package vaultWeb.controllers; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import vaultWeb.dtos.ChatImageUploadResponse; +import vaultWeb.models.User; +import vaultWeb.services.ChatImageService; +import vaultWeb.services.auth.AuthService; + +@RestController +@RequestMapping("/api/chat") +@Tag(name = "Chat Image Controller", description = "Handles image uploads for chat messages") +@RequiredArgsConstructor +@Slf4j +public class ChatImageController { + + private final ChatImageService chatImageService; + private final AuthService authService; + + @Value("${vault.chatImage.maxSizeBytes:5242880}") // default 5 MB + private long maxFileSizeBytes; + + @Value("${vault.chatImage.allowedMimeTypes:image/jpeg,image/png,image/gif,image/webp}") + private String allowedMimeTypesProp; + + /** + * Handles multipart image uploads for chat messages. + * + *

Performs basic validation on the uploaded file (non-empty, size limit, allowed MIME types) + * and delegates to {@link vaultWeb.services.ChatImageService} to persist the image and validate + * sender/receiver users. + * + * @param imageFile the uploaded multipart image file (field name: {@code image}) The sender is + * derived from the currently authenticated user; clients must NOT provide it. + * @param receiverUserId the ID of the receiving user (must not be null) + * @return a 200 OK response containing a structured JSON body with the new image ID + * @throws IllegalArgumentException if the file is empty/invalid or if IDs are null + */ + @PostMapping(value = "/upload-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Upload Chat Image", + description = + "Uploads an image to be used in chat messages. The sender is the currently authenticated user.") + public ResponseEntity uploadChatImage( + @RequestParam("image") MultipartFile imageFile, @RequestParam Integer receiverUserId) { + try { + // Derive sender from the authenticated user + User currentUser = authService.getCurrentUser(); + if (currentUser == null) { + return ResponseEntity.status(401) + .body(new ChatImageUploadResponse("Unauthorized: user is not authenticated", null)); + } + byte[] imageByteArray = validateAndReadImage(imageFile); + Long imageId = + chatImageService.uploadChatImage( + imageByteArray, currentUser.getId().intValue(), receiverUserId); + ChatImageUploadResponse body = + new ChatImageUploadResponse("Image uploaded successfully", imageId); + return ResponseEntity.ok(body); + } catch (IOException e) { + // Log the root cause for diagnostics while returning a stable, user-friendly message + log.error("Failed to read/process uploaded image file", e); + return ResponseEntity.status(500) + .body(new ChatImageUploadResponse("Failed to process image file", null)); + } + } + + private byte[] validateAndReadImage(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Image file cannot be empty"); + } + if (file.getSize() > maxFileSizeBytes) { + throw new IllegalArgumentException("Image file too large"); + } + + // Read the bytes only once and validate using magic bytes (server-side detection) + byte[] bytes = file.getBytes(); + + String detectedMime = detectMimeType(bytes); + Set allowed = new HashSet<>(Arrays.asList(allowedMimeTypesProp.split(","))); + if (detectedMime == null || !allowed.contains(detectedMime)) { + throw new IllegalArgumentException( + "Unsupported image type. Detected: " + detectedMime + ". Allowed: " + allowed); + } + + return bytes; + } + + // Very small, fast signature checks for common image formats + private String detectMimeType(byte[] bytes) { + if (bytes == null || bytes.length < 12) { + return null; + } + + // JPEG: FF D8 FF + if ((bytes[0] & 0xFF) == 0xFF && (bytes[1] & 0xFF) == 0xD8 && (bytes[2] & 0xFF) == 0xFF) { + return "image/jpeg"; + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ((bytes[0] & 0xFF) == 0x89 + && bytes[1] == 0x50 + && bytes[2] == 0x4E + && bytes[3] == 0x47 + && bytes[4] == 0x0D + && bytes[5] == 0x0A + && bytes[6] == 0x1A + && bytes[7] == 0x0A) { + return "image/png"; + } + + // GIF: "GIF87a" or "GIF89a" + if (bytes.length >= 6) { + String sig = new String(bytes, 0, 6); + if ("GIF87a".equals(sig) || "GIF89a".equals(sig)) { + return "image/gif"; + } + } + + // WEBP: RIFF....WEBP + if (bytes[0] == 'R' + && bytes[1] == 'I' + && bytes[2] == 'F' + && bytes[3] == 'F' + && bytes[8] == 'W' + && bytes[9] == 'E' + && bytes[10] == 'B' + && bytes[11] == 'P') { + return "image/webp"; + } + + return null; + } +} diff --git a/backend/src/main/java/vaultWeb/dtos/ChatImageUploadResponse.java b/backend/src/main/java/vaultWeb/dtos/ChatImageUploadResponse.java new file mode 100644 index 000000000..799561bf6 --- /dev/null +++ b/backend/src/main/java/vaultWeb/dtos/ChatImageUploadResponse.java @@ -0,0 +1,11 @@ +package vaultWeb.dtos; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ChatImageUploadResponse { + private String message; + private Long imageId; +} diff --git a/backend/src/main/java/vaultWeb/exceptions/GlobalExceptionHandler.java b/backend/src/main/java/vaultWeb/exceptions/GlobalExceptionHandler.java index f0e6cdbd8..f0490362a 100644 --- a/backend/src/main/java/vaultWeb/exceptions/GlobalExceptionHandler.java +++ b/backend/src/main/java/vaultWeb/exceptions/GlobalExceptionHandler.java @@ -1,11 +1,14 @@ package vaultWeb.exceptions; import java.nio.file.AccessDeniedException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.util.unit.DataSize; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import vaultWeb.exceptions.notfound.GroupNotFoundException; import vaultWeb.exceptions.notfound.NotMemberException; import vaultWeb.exceptions.notfound.UserNotFoundException; @@ -107,10 +110,55 @@ public ResponseEntity handlePollOptionNotFound(PollOptionNotFoundExcepti return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Poll error: " + ex.getMessage()); } + /** Handles IllegalArgumentException (validation failures) and returns 400 Bad Request. */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Bad request: " + ex.getMessage()); + } + + /** Handles MaxUploadSizeExceededException (multipart too large) and returns 400 Bad Request. */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSize(MaxUploadSizeExceededException ex) { + // Provide a concise, user-friendly message without exposing internal details + String sizeLabel = resolveConfiguredMaxUploadSizeLabel(); + String message = + (sizeLabel != null) + ? "File size exceeds the maximum allowed limit of " + sizeLabel + : "File size exceeds the maximum allowed limit"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message); + } + /** Handles any other RuntimeException and returns 500 Internal Server Error. */ @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeException(RuntimeException ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body("Internal error: " + ex.getMessage()); } + + // --- Helpers --- + + @Value("${spring.servlet.multipart.max-file-size:}") + private String maxUploadSizeProp; + + private String resolveConfiguredMaxUploadSizeLabel() { + try { + if (maxUploadSizeProp == null || maxUploadSizeProp.isBlank()) { + return null; + } + DataSize size = DataSize.parse(maxUploadSizeProp.trim()); + long bytes = size.toBytes(); + // Prefer MB if evenly divisible, then KB, otherwise bytes + long mb = bytes / (1024 * 1024); + if (mb > 0 && (bytes % (1024 * 1024) == 0)) { + return mb + "MB"; + } + long kb = bytes / 1024; + if (kb > 0 && (bytes % 1024 == 0)) { + return kb + "KB"; + } + return bytes + "B"; + } catch (Exception e) { + return null; // Fallback to generic message on parse issues + } + } } diff --git a/backend/src/main/java/vaultWeb/models/ChatImage.java b/backend/src/main/java/vaultWeb/models/ChatImage.java new file mode 100644 index 000000000..69a24ed7b --- /dev/null +++ b/backend/src/main/java/vaultWeb/models/ChatImage.java @@ -0,0 +1,31 @@ +package vaultWeb.models; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import java.time.OffsetDateTime; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Table(name = "chat_images") +public class ChatImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Lob + @Column(name = "image_content", nullable = false, columnDefinition = "bytea") + private byte[] imageContent; + + @Column(name = "sender_id") + private Integer senderId; + + @Column(name = "receiver_id") + private Integer receiverId; + + @NotNull + @Column(name = "createdon", nullable = false) + private OffsetDateTime createdon; +} diff --git a/backend/src/main/java/vaultWeb/repositories/ChatImageRepo.java b/backend/src/main/java/vaultWeb/repositories/ChatImageRepo.java new file mode 100644 index 000000000..8dbab1e62 --- /dev/null +++ b/backend/src/main/java/vaultWeb/repositories/ChatImageRepo.java @@ -0,0 +1,28 @@ +package vaultWeb.repositories; + +import java.time.OffsetDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import vaultWeb.models.ChatImage; + +@Repository +public interface ChatImageRepo extends CrudRepository { + + @Transactional + @Query( + value = + "INSERT INTO chat_images (image_content, receiver_id, sender_id, createdon) " + + "VALUES (CAST(:imageData AS bytea), :receiverId, :senderId, :createdon) RETURNING id", + nativeQuery = true) + Long saveImage( + @Param("imageData") byte[] imageData, + @Param("senderId") Integer senderId, + @Param("receiverId") Integer receiverId, + @Param("createdon") OffsetDateTime createdon); + + List createdon(OffsetDateTime createdon); +} diff --git a/backend/src/main/java/vaultWeb/services/ChatImageService.java b/backend/src/main/java/vaultWeb/services/ChatImageService.java new file mode 100644 index 000000000..1362198b1 --- /dev/null +++ b/backend/src/main/java/vaultWeb/services/ChatImageService.java @@ -0,0 +1,52 @@ +package vaultWeb.services; + +import java.time.OffsetDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import vaultWeb.exceptions.notfound.UserNotFoundException; +import vaultWeb.repositories.ChatImageRepo; +import vaultWeb.repositories.UserRepository; + +@Service +@RequiredArgsConstructor +public class ChatImageService { + + private final ChatImageRepo chatImageRepo; + private final UserRepository userRepository; + + /** + * Persists a chat image payload and links it to the given sender and receiver users. + * + *

Validates that both {@code senderUserId} and {@code receiverUserId} are provided and refer + * to existing users in the system before storing the image. The image bytes are inserted using a + * native query optimized for PostgreSQL bytea. + * + * @param imageBytes raw image bytes to store (already validated for size/type upstream) + * @param senderUserId ID of the user sending the image (must not be null; must exist) + * @param receiverUserId ID of the user receiving the image (must not be null; must exist) + * @return the generated image record ID + * @throws IllegalArgumentException if either user ID is null + * @throws UserNotFoundException if either the sender or receiver cannot be found + */ + public Long uploadChatImage(byte[] imageBytes, Integer senderUserId, Integer receiverUserId) { + if (senderUserId == null) { + throw new IllegalArgumentException("senderUserId must not be null"); + } + if (receiverUserId == null) { + throw new IllegalArgumentException("receiverUserId must not be null"); + } + + userRepository + .findById(Long.valueOf(senderUserId)) + .orElseThrow( + () -> new UserNotFoundException("Sender with id " + senderUserId + " not found")); + userRepository + .findById(Long.valueOf(receiverUserId)) + .orElseThrow( + () -> new UserNotFoundException("Receiver with id " + receiverUserId + " not found")); + + OffsetDateTime createdon = OffsetDateTime.now(); + Long ref = chatImageRepo.saveImage(imageBytes, senderUserId, receiverUserId, createdon); + return ref; + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index d9855ec45..4fee01e62 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -18,3 +18,11 @@ springdoc.swagger-ui.tagsSorter=alpha encryption.master-key=aRvdZ53Fuwf7nfoT4+VeKPYi4XOhpTSh4eshUfZIgVs= # JWT Secret Key for signing tokens jwt.secret=ab9bb63c49d6d8b4029a1e6e3b1947d34be053f8ce5a0ee391e46f393014694e + +# Chat image upload validation +vault.chatImage.maxSizeBytes=5242880 +vault.chatImage.allowedMimeTypes=image/jpeg,image/png,image/gif,image/webp + +# Enforce multipart upload size limits at Spring level (keep in sync with vault.chatImage.maxSizeBytes) +spring.servlet.multipart.max-file-size=5MB +spring.servlet.multipart.max-request-size=5MB