Skip to content
Open
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
148 changes: 148 additions & 0 deletions backend/src/main/java/vaultWeb/controllers/ChatImageController.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<ChatImageUploadResponse> 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<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package vaultWeb.dtos;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ChatImageUploadResponse {
private String message;
private Long imageId;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -107,10 +110,55 @@ public ResponseEntity<String> 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<String> 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<String> 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<String> 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
}
}
}
31 changes: 31 additions & 0 deletions backend/src/main/java/vaultWeb/models/ChatImage.java
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions backend/src/main/java/vaultWeb/repositories/ChatImageRepo.java
Original file line number Diff line number Diff line change
@@ -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<ChatImage, Long> {

@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<ChatImage> createdon(OffsetDateTime createdon);
}
52 changes: 52 additions & 0 deletions backend/src/main/java/vaultWeb/services/ChatImageService.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
8 changes: 8 additions & 0 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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