From 010955005f008db518b7110d1e19e52d14257a4e Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Mon, 13 Apr 2026 09:25:24 +0200 Subject: [PATCH] Skip preview generation for images with large decoded size to prevent OOM When uploading high-resolution images (e.g. 17869x12802), the decoded BufferedImage can consume ~915MB of heap, causing OOM on tb-core. Now reads image dimensions from metadata before decoding and skips preview generation if decoded size (width*height*4) exceeds the tenant's maxResourceSize, saving a 1x1 transparent GIF placeholder instead. --- .../controller/ImageControllerTest.java | 32 +++++++++++++++++++ .../server/dao/resource/BaseImageService.java | 30 +++++++++++++++-- .../validator/ResourceDataValidator.java | 7 ++++ .../server/dao/util/ImageUtils.java | 27 ++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java index 3fbe4bd845..912820c723 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.sql.resource.TbResourceRepository; +import org.thingsboard.server.dao.util.ImageUtils; import java.util.Base64; import java.util.List; @@ -322,6 +323,37 @@ public class ImageControllerTest extends AbstractControllerTest { assertThat(systemParams.getMaxResourceSize()).isEqualTo(0); } + @Test + public void testSkipPreviewForLargeImage() throws Exception { + // PNG_IMAGE is 200x160 = 128000 bytes decoded (200*160*4) + // maxResourceSize must be > file size (so upload passes) but < decoded size (so preview is skipped) + loginSysAdmin(); + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setMaxResourceSize(50000); + }); + loginTenantAdmin(); + + String filename = "large_decoded_image.png"; + TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE); + + ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class); + assertThat(imageDescriptor.getWidth()).isEqualTo(200); + assertThat(imageDescriptor.getHeight()).isEqualTo(160); + + ImageDescriptor previewDescriptor = imageDescriptor.getPreviewDescriptor(); + assertThat(previewDescriptor.getMediaType()).isEqualTo("image/gif"); + assertThat(previewDescriptor.getWidth()).isEqualTo(1); + assertThat(previewDescriptor.getHeight()).isEqualTo(1); + + assertThat(downloadImagePreview("tenant", filename)).containsExactly(ImageUtils.PLACEHOLDER_PREVIEW); + + // Reset maxResourceSize + loginSysAdmin(); + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setMaxResourceSize(0); + }); + } + @Test public void testInlineImages() throws Exception { TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", "my_png_image.png", "image/png", PNG_IMAGE); diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 639c60e1d6..3df2d945dc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -138,7 +138,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic resourceValidator.validate(image, TbResourceInfo::getTenantId); ImageDescriptor descriptor = image.getDescriptor(ImageDescriptor.class); - Pair result = processImage(image.getData(), descriptor); + long maxDecodedImageSize = resourceValidator.getMaxResourceSize(image.getTenantId()); + Pair result = processImage(image.getData(), descriptor, maxDecodedImageSize); descriptor = result.getLeft(); image.setEtag(descriptor.getEtag()); image.setDescriptorValue(descriptor); @@ -152,7 +153,32 @@ public class BaseImageService extends BaseResourceService implements ImageServic return new TbResourceInfo(doSaveResource(image)); } - private Pair processImage(byte[] data, ImageDescriptor descriptor) throws Exception { + private Pair processImage(byte[] data, ImageDescriptor descriptor, long maxDecodedImageSize) throws Exception { + if (maxDecodedImageSize > 0) { + int[] dimensions = ImageUtils.getImageDimensions(data, descriptor.getMediaType()); + if (dimensions != null) { + long decodedSize = (long) dimensions[0] * dimensions[1] * 4; + if (decodedSize > maxDecodedImageSize) { + log.info("Image decoded size ({} bytes) exceeds max decoded image size ({} bytes), skipping preview generation", + decodedSize, maxDecodedImageSize); + descriptor.setWidth(dimensions[0]); + descriptor.setHeight(dimensions[1]); + descriptor.setSize(data.length); + descriptor.setEtag(calculateEtag(data)); + + ImageDescriptor previewDescriptor = new ImageDescriptor(); + previewDescriptor.setWidth(1); + previewDescriptor.setHeight(1); + previewDescriptor.setMediaType("image/gif"); + previewDescriptor.setSize(ImageUtils.PLACEHOLDER_PREVIEW.length); + previewDescriptor.setEtag(calculateEtag(ImageUtils.PLACEHOLDER_PREVIEW)); + descriptor.setPreviewDescriptor(previewDescriptor); + + return Pair.of(descriptor, ImageUtils.PLACEHOLDER_PREVIEW); + } + } + } + ProcessedImage image = ImageUtils.processImage(data, descriptor.getMediaType(), 250); ProcessedImage preview = image.getPreview(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java index 5bcd4c0613..ca81aecf96 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java @@ -89,6 +89,13 @@ public class ResourceDataValidator extends DataValidator { } } + public long getMaxResourceSize(TenantId tenantId) { + if (tenantId.isSysTenantId()) { + return 0; + } + return tenantProfileCache.get(tenantId).getDefaultProfileConfiguration().getMaxResourceSize(); + } + public void validateResourceSize(TenantId tenantId, TbResourceId resourceId, long dataSize) { if (!tenantId.isSysTenantId()) { DefaultTenantProfileConfiguration profileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java b/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java index 6ddafde5b4..3d53e9fd10 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java @@ -41,13 +41,16 @@ import org.thingsboard.common.util.JacksonUtil; import javax.imageio.IIOImage; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; import javax.imageio.ImageTypeSpecifier; import javax.imageio.ImageWriteParam; import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageInputStream; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Map; @@ -57,6 +60,9 @@ import java.util.regex.Pattern; @Slf4j public class ImageUtils { + public static final byte[] PLACEHOLDER_PREVIEW = Base64.getDecoder().decode( + "R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="); + private static final Map mediaTypeMappings = Map.of( "jpeg", "jpg", "svg+xml", "svg", @@ -75,6 +81,27 @@ public class ImageUtils { return new MimeType("image", subtype).toString(); } + public static int[] getImageDimensions(byte[] data, String mediaType) { + if (mediaTypeToFileExtension(mediaType).equals("svg")) { + return null; + } + try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) { + var readers = ImageIO.getImageReaders(iis); + if (readers.hasNext()) { + ImageReader reader = readers.next(); + try { + reader.setInput(iis); + return new int[]{reader.getWidth(0), reader.getHeight(0)}; + } finally { + reader.dispose(); + } + } + } catch (IOException e) { + log.warn("Couldn't read image dimensions from metadata: {}", ExceptionUtils.getMessage(e)); + } + return null; + } + public static ProcessedImage processImage(byte[] data, String mediaType, int thumbnailMaxDimension) throws Exception { if (mediaTypeToFileExtension(mediaType).equals("svg")) { try {