Browse Source

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.
pull/15411/head
Oleksandra Matviienko 2 months ago
parent
commit
010955005f
  1. 32
      application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java
  2. 30
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
  3. 7
      dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java
  4. 27
      dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java

32
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);

30
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<ImageDescriptor, byte[]> result = processImage(image.getData(), descriptor);
long maxDecodedImageSize = resourceValidator.getMaxResourceSize(image.getTenantId());
Pair<ImageDescriptor, byte[]> 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<ImageDescriptor, byte[]> processImage(byte[] data, ImageDescriptor descriptor) throws Exception {
private Pair<ImageDescriptor, byte[]> 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();

7
dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java

@ -89,6 +89,13 @@ public class ResourceDataValidator extends DataValidator<TbResource> {
}
}
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();

27
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<String, String> 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 {

Loading…
Cancel
Save