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