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
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ void shouldFailToUploadOversizedFile() {
courtsWithPhotos.add(courtId);

final File oversizedFile = new File(
"src/functionalTest/resources/test-images/test invalid jpg 3.2 MB.jpg");
"src/functionalTest/resources/test-images/test invalid jpg 5.3 MB.jpg");
final Response uploadResponse = http.doMultipartPost(
"/courts/" + courtId + "/v1/photo",
"file",
Expand Down
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package uk.gov.hmcts.reform.fact.data.api.config.properties;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "fact.data-api.photo", ignoreUnknownFields = false)
@Getter
@Setter
public class PhotoConfigurationProperties {
@Min(256)
@Max(2048)
private int maxWidth = 1024;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import uk.gov.hmcts.reform.fact.data.api.migration.entities.MigrationStatus;
import uk.gov.hmcts.reform.fact.data.api.migration.exception.MigrationAlreadyAppliedException;
import uk.gov.hmcts.reform.fact.data.api.migration.model.CourtPhotoDto;
import uk.gov.hmcts.reform.fact.data.api.migration.model.InMemoryMultipartFile;
import uk.gov.hmcts.reform.fact.data.api.models.InMemoryMultipartFile;
import uk.gov.hmcts.reform.fact.data.api.migration.model.LegacyExportResponse;
import uk.gov.hmcts.reform.fact.data.api.migration.model.PhotoMigrationResponse;
import uk.gov.hmcts.reform.fact.data.api.migration.repository.LegacyCourtMappingRepository;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package uk.gov.hmcts.reform.fact.data.api.migration.model;
package uk.gov.hmcts.reform.fact.data.api.models;

import lombok.AllArgsConstructor;
import lombok.Getter;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
package uk.gov.hmcts.reform.fact.data.api.services;

import uk.gov.hmcts.reform.fact.data.api.audit.AuditUserContext;
import uk.gov.hmcts.reform.fact.data.api.config.properties.PhotoConfigurationProperties;
import uk.gov.hmcts.reform.fact.data.api.entities.CourtPhoto;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException;
import uk.gov.hmcts.reform.fact.data.api.models.InMemoryMultipartFile;
import uk.gov.hmcts.reform.fact.data.api.repositories.CourtPhotoRepository;

import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Locale;
import java.util.UUID;
import javax.imageio.ImageIO;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -17,10 +26,15 @@
@RequiredArgsConstructor
public class CourtPhotoService {

private static final String PNG = "png";
private static final String JPG = "jpg";
private static final String JPEG = "jpeg";

private final CourtPhotoRepository courtPhotoRepository;
private final CourtService courtService;
private final AzureBlobService azureBlobService;
private final AuditUserContext auditUserContext;
private final PhotoConfigurationProperties photoConfigurationProperties;

/**
* Get a court photo by court id.
Expand All @@ -46,11 +60,13 @@ public CourtPhoto getCourtPhotoByCourtId(UUID courtId) {
public CourtPhoto setCourtPhoto(UUID courtId, MultipartFile file) {
courtService.getCourtById(courtId);

MultipartFile resizedFile = resizeIfNeeded(file);

CourtPhoto courtPhoto = courtPhotoRepository.findCourtPhotoByCourtId(courtId)
.orElse(new CourtPhoto());

courtPhoto.setCourtId(courtId);
courtPhoto.setFileLink(azureBlobService.uploadFile(courtId.toString(), file));
courtPhoto.setFileLink(azureBlobService.uploadFile(courtId.toString(), resizedFile));
courtPhoto.setUpdatedByUserId(auditUserContext.requireUserId());

return courtPhotoRepository.save(courtPhoto);
Expand All @@ -71,4 +87,71 @@ public void deleteCourtPhotoByCourtId(UUID courtId) {
azureBlobService.deleteBlob(courtPhoto.getCourtId().toString());
courtPhotoRepository.deleteById(courtPhoto.getId());
}

private MultipartFile resizeIfNeeded(MultipartFile file) {
String format = detectImageFormat(file.getOriginalFilename(), file.getContentType());
int maxWidth = photoConfigurationProperties.getMaxWidth();

try {
BufferedImage sourceImage = ImageIO.read(file.getInputStream());
if (sourceImage == null) {
throw new IllegalArgumentException("Unable to read image content");
}

if (sourceImage.getWidth() <= maxWidth) {
return file;
}

int newHeight = (int) Math.round((double) sourceImage.getHeight() * maxWidth / sourceImage.getWidth());

int imageType = PNG.equals(format) ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
BufferedImage resizedImage = new BufferedImage(maxWidth, newHeight, imageType);

Graphics2D graphics = resizedImage.createGraphics();
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics.drawImage(sourceImage, 0, 0, maxWidth, newHeight, null);
graphics.dispose();

ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
if (!ImageIO.write(resizedImage, format, outputStream)) {
throw new IllegalStateException("No ImageIO writer found for format: " + format);
}

return new InMemoryMultipartFile(
file.getName(),
file.getOriginalFilename(),
file.getContentType(),
outputStream.toByteArray()
);
} catch (IOException ex) {
throw new IllegalArgumentException("Failed to process uploaded image", ex);
}
}

private String detectImageFormat(String originalFilename, String contentType) {
if (contentType != null) {
String lower = contentType.toLowerCase(Locale.ROOT);
if (lower.contains(PNG)) {
return PNG;
}
if (lower.contains(JPEG) || lower.contains(JPG)) {
return JPG;
}
}

if (originalFilename != null) {
String lower = originalFilename.toLowerCase(Locale.ROOT);
if (lower.endsWith("." + PNG)) {
return PNG;
}
if (lower.endsWith("." + JPG) || lower.endsWith("." + JPEG)) {
return JPG;
}
}

// Defensive fallback as upstream validation should already ensure png/jpg.
return JPG;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import uk.gov.hmcts.reform.fact.data.api.entities.types.HearingEnhancementEquipment;
import uk.gov.hmcts.reform.fact.data.api.entities.types.OpeningTimesDetail;
import uk.gov.hmcts.reform.fact.data.api.entities.types.UserRole;
import uk.gov.hmcts.reform.fact.data.api.migration.model.InMemoryMultipartFile;
import uk.gov.hmcts.reform.fact.data.api.models.InMemoryMultipartFile;
import uk.gov.hmcts.reform.fact.data.api.models.AreaOfLawSelectionDto;
import uk.gov.hmcts.reform.fact.data.api.models.CourtLocalAuthorityDto;
import uk.gov.hmcts.reform.fact.data.api.models.LocalAuthoritySelectionDto;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidFileException;
import uk.gov.hmcts.reform.fact.data.api.validation.annotations.ValidImage;

import java.io.IOException;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.InputStream;
import java.util.Iterator;
import java.util.Locale;

public class ImageValidator implements ConstraintValidator<ValidImage, MultipartFile> {

Expand All @@ -17,41 +22,55 @@ public void initialize(ValidImage constraintAnnotation) {

@Override
public boolean isValid(MultipartFile file, ConstraintValidatorContext context) {

if (file == null || file.isEmpty()) {
throw new InvalidFileException("File is empty");
}

try {
byte[] b = file.getBytes();
if (!isPng(b) && !isJpeg(b)) {
try (InputStream inputStream = file.getInputStream();
ImageInputStream imageInputStream = ImageIO.createImageInputStream(inputStream)) {

if (imageInputStream == null) {
throw new InvalidFileException("Unreadable file");
}

Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInputStream);
if (!readers.hasNext()) {
throw new InvalidFileException("Only JPEG or PNG files are allowed");
}
} catch (IOException e) {
throw new InvalidFileException("Unreadable file");
}

return true;
}
ImageReader reader = readers.next();
String format;
try {
reader.setInput(imageInputStream, true, true);
format = reader.getFormatName();

private boolean isPng(byte[] b) {
return b.length >= 8
&& b[0] == (byte)0x89
&& b[1] == (byte)0x50
&& b[2] == (byte)0x4E
&& b[3] == (byte)0x47
&& b[4] == (byte)0x0D
&& b[5] == (byte)0x0A
&& b[6] == (byte)0x1A
&& b[7] == (byte)0x0A;
if (!isAllowedFormat(format)) {
throw new InvalidFileException("Only JPEG or PNG files are allowed");
}

// Force actual decode metadata/content read - truncated streams fail here.
int width = reader.getWidth(0);
int height = reader.getHeight(0);
if (width <= 0 || height <= 0) {
throw new InvalidFileException("Unreadable file");
}
} finally {
reader.dispose();
}

return true;
} catch (InvalidFileException e) {
throw e;
} catch (Exception e) {
throw new InvalidFileException("Unreadable file");
}
}

private boolean isJpeg(byte[] b) {
return b.length >= 4
&& b[0] == (byte)0xFF
&& b[1] == (byte)0xD8
&& b[b.length - 2] == (byte)0xFF
&& b[b.length - 1] == (byte)0xD9;
private boolean isAllowedFormat(String format) {
if (format == null) {
return false;
}
String normalized = format.toLowerCase(Locale.ROOT);
return "jpeg".equals(normalized) || "jpg".equals(normalized) || "png".equals(normalized);
}
}

4 changes: 3 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ spring:

servlet:
multipart:
max-file-size: 2MB
max-file-size: 4MB
data:
web:
pageable:
Expand Down Expand Up @@ -120,3 +120,5 @@ fact:
data-api:
audit:
retention-days: ${AUDIT_RETENTION_DAYS:365}
photo:
max-width: ${PHOTO_MAX_WIDTH:1024}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

import static org.assertj.core.api.Assertions.assertThat;

import uk.gov.hmcts.reform.fact.data.api.models.InMemoryMultipartFile;

class InMemoryMultipartFileTest {

@TempDir
Expand Down
Loading
Loading