From 3166f1dd5d50538dafc3359e180e8b77818b28c4 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Sat, 13 Dec 2025 17:14:40 -0600 Subject: [PATCH] Make height/width nullable; add support for reading svgs; better error handling. --- build.gradle | 6 ++ .../app/mealsmadeeasy/api/image/Image.java | 4 +- .../api/image/ImageException.java | 2 +- .../api/image/S3ImageEntity.java | 14 ++-- .../api/image/S3ImageService.java | 70 +++++++++++++------ .../api/image/view/ImageView.java | 12 ++-- 6 files changed, 69 insertions(+), 39 deletions(-) diff --git a/build.gradle b/build.gradle index ca692ad..fbbd30b 100644 --- a/build.gradle +++ b/build.gradle @@ -69,6 +69,12 @@ dependencies { // https://mvnrepository.com/artifact/com.twelvemonkeys.imageio/imageio-webp runtimeOnly 'com.twelvemonkeys.imageio:imageio-webp:3.12.0' + // https://mvnrepository.com/artifact/com.twelvemonkeys.imageio/imageio-batik + runtimeOnly 'com.twelvemonkeys.imageio:imageio-batik:3.12.0' + + // https://mvnrepository.com/artifact/org.apache.xmlgraphics/batik-all + runtimeOnly 'org.apache.xmlgraphics:batik-all:1.19' + compileOnly 'org.jetbrains:annotations:24.1.0' // Custom testing diff --git a/src/main/java/app/mealsmadeeasy/api/image/Image.java b/src/main/java/app/mealsmadeeasy/api/image/Image.java index 7c26673..cc787de 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/Image.java +++ b/src/main/java/app/mealsmadeeasy/api/image/Image.java @@ -16,7 +16,7 @@ public interface Image { @Nullable String getCaption(); User getOwner(); boolean isPublic(); - int getHeight(); - int getWidth(); + @Nullable Integer getHeight(); + @Nullable Integer getWidth(); Set getViewers(); } diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageException.java b/src/main/java/app/mealsmadeeasy/api/image/ImageException.java index 50d754c..2bf5581 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageException.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageException.java @@ -6,7 +6,7 @@ public class ImageException extends Exception { INVALID_ID, INVALID_USERNAME_OR_FILENAME, IMAGE_NOT_FOUND, - UNKNOWN_MIME_TYPE + UNSUPPORTED_IMAGE_TYPE, } private final Type type; diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java index ba26784..51df89b 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java @@ -35,11 +35,9 @@ public class S3ImageEntity implements Image { @Column(nullable = false) private String objectName; - @Column(nullable = false) - private int height; + private Integer height; - @Column(nullable = false) - private int width; + private Integer width; @ManyToOne(optional = false) @JoinColumn(name = "owner_id", nullable = false) @@ -123,20 +121,20 @@ public class S3ImageEntity implements Image { } @Override - public int getHeight() { + public @Nullable Integer getHeight() { return this.height; } - public void setHeight(int height) { + public void setHeight(Integer height) { this.height = height; } @Override - public int getWidth() { + public @Nullable Integer getWidth() { return this.width; } - public void setWidth(int width) { + public void setWidth(Integer width) { this.width = width; } diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java index 0ad1aab..9ed7bf8 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java @@ -7,8 +7,6 @@ import app.mealsmadeeasy.api.s3.S3Manager; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserEntity; import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; @@ -16,6 +14,8 @@ import org.springframework.stereotype.Service; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.time.LocalDateTime; @@ -27,7 +27,11 @@ import java.util.regex.Pattern; public class S3ImageService implements ImageService { private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$"); - private static final Logger logger = LoggerFactory.getLogger(S3ImageService.class); + + private static final String IMAGE_JPEG = "image/jpeg"; + private static final String IMAGE_PNG = "image/png"; + private static final String IMAGE_SVG = "image/svg+xml"; + private static final String IMAGE_WEBP = "image/webp"; private final S3Manager s3Manager; private final S3ImageRepository imageRepository; @@ -51,10 +55,10 @@ public class S3ImageService implements ImageService { if (m.matches()) { final String extension = m.group(1); return switch (extension) { - case "jpg", "jpeg" -> "image/jpeg"; - case "png" -> "image/png"; - case "svg" -> "image/svg+xml"; - case "webp" -> "image/webp"; + case "jpg", "jpeg" -> IMAGE_JPEG; + case "png" -> IMAGE_PNG; + case "svg" -> IMAGE_SVG; + case "webp" -> IMAGE_WEBP; default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension); }; } else { @@ -64,13 +68,13 @@ public class S3ImageService implements ImageService { private String getExtension(String mimeType) throws ImageException { return switch (mimeType) { - case "image/jpeg" -> "jpg"; - case "image/png" -> "png"; - case "image/svg+xml" -> "svg"; - case "image/webp" -> "webp"; + case IMAGE_JPEG -> "jpg"; + case IMAGE_PNG -> "png"; + case IMAGE_SVG -> "svg"; + case IMAGE_WEBP -> "webp"; default -> throw new ImageException( - ImageException.Type.UNKNOWN_MIME_TYPE, - "Unknown mime type: " + mimeType + ImageException.Type.UNSUPPORTED_IMAGE_TYPE, + "Unsupported mime type: " + mimeType ); }; } @@ -101,6 +105,18 @@ public class S3ImageService implements ImageService { return didTransfer; } + /** + * @apiNote Consumes and closes the {@link java.io.InputStream}. + * + * @param owner the User owner + * @param userFilename the name of the uploaded file from the user + * @param inputStream the image content + * @param objectSize the size of the image, in bytes + * @param createSpec the metadata for the image + * @return an {@link app.mealsmadeeasy.api.image.Image} representing the stored image + * @throws IOException if there are any errors related to IO while storing the image + * @throws ImageException if there are any errors processing the image + */ @Override public Image create( User owner, @@ -113,19 +129,29 @@ public class S3ImageService implements ImageService { final String uuid = UUID.randomUUID().toString(); final String extension = this.getExtension(mimeType); final String filename = uuid + "." + extension; + + final var baos = new ByteArrayOutputStream(); + inputStream.transferTo(baos); + final InputStream toStore = new ByteArrayInputStream(baos.toByteArray()); + final InputStream toRead = new ByteArrayInputStream(baos.toByteArray()); + final String objectName = this.s3Manager.store( - this.imageBucketName, filename, mimeType, inputStream, objectSize + this.imageBucketName, filename, mimeType, toStore, objectSize ); - final int height, width; - try (final InputStream imageContent = this.s3Manager.load(this.imageBucketName, objectName)) { - final BufferedImage bufferedImage = ImageIO.read(imageContent); - if (bufferedImage == null) { - logger.error("ImageIO could not read image: {} ({})", userFilename, objectName); - } - height = bufferedImage.getHeight(); - width = bufferedImage.getWidth(); + final BufferedImage bufferedImage = ImageIO.read(toRead); + if (bufferedImage == null) { + throw new ImageException( + ImageException.Type.UNSUPPORTED_IMAGE_TYPE, + "ImageIO could not read image: " + userFilename + ); } + final int height = bufferedImage.getHeight(); + final int width = bufferedImage.getWidth(); + + toRead.close(); + toStore.close(); + inputStream.close(); final S3ImageEntity draft = new S3ImageEntity(); draft.setOwner((UserEntity) owner); diff --git a/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java b/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java index 32a54af..7fcce56 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java +++ b/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java @@ -41,8 +41,8 @@ public class ImageView { private @Nullable String caption; private UserInfoView owner; private boolean isPublic; - private int height; - private int width; + private @Nullable Integer height; + private @Nullable Integer width; private @Nullable Set viewers; public String getUrl() { @@ -117,19 +117,19 @@ public class ImageView { this.isPublic = isPublic; } - public int getHeight() { + public @Nullable Integer getHeight() { return this.height; } - public void setHeight(int height) { + public void setHeight(Integer height) { this.height = height; } - public int getWidth() { + public @Nullable Integer getWidth() { return this.width; } - public void setWidth(int width) { + public void setWidth(Integer width) { this.width = width; }