diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java new file mode 100644 index 00000000..076f80c2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java @@ -0,0 +1,79 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import org.springframework.stereotype.Component; + +/** Decodes a final crop and writes fresh pixels, without client-supplied metadata. */ +@Component +public class SkillAvatarImageProcessor { + public static final int MAX_BYTES = 2 * 1024 * 1024; + public static final int MAX_SIDE = 1024; + + public byte[] normalize(byte[] input) { + if (input == null || input.length == 0 || input.length > MAX_BYTES) { + throw invalidImage(); + } + boolean png = input.length >= 8 && (input[0] & 0xff) == 0x89 + && input[1] == 'P' && input[2] == 'N' && input[3] == 'G' + && (input[4] & 0xff) == 13 && input[5] == 10 && (input[6] & 0xff) == 26 && input[7] == 10; + boolean jpeg = input.length >= 3 && (input[0] & 0xff) == 0xff + && (input[1] & 0xff) == 0xd8 && (input[2] & 0xff) == 0xff; + if (!png && !jpeg) { + throw invalidImage(); + } + try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(input))) { + if (stream == null) { + throw invalidImage(); + } + var readers = ImageIO.getImageReaders(stream); + if (!readers.hasNext()) { + throw invalidImage(); + } + ImageReader reader = readers.next(); + try { + if (!((png && "png".equalsIgnoreCase(reader.getFormatName())) + || (jpeg && "jpeg".equalsIgnoreCase(reader.getFormatName())))) { + throw invalidImage(); + } + reader.setInput(stream, true, true); + int width = reader.getWidth(0); + int height = reader.getHeight(0); + if (width < 1 || height < 1 || width != height || width > MAX_SIDE) { + throw invalidImage(); + } + BufferedImage decoded = reader.read(0); + if (decoded == null) { + throw invalidImage(); + } + BufferedImage clean = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = clean.createGraphics(); + try { + graphics.drawImage(decoded, 0, 0, null); + } finally { + graphics.dispose(); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(clean, "png", output) || output.size() > MAX_BYTES) { + throw invalidImage(); + } + return output.toByteArray(); + } finally { + reader.dispose(); + } + } catch (IOException | IllegalArgumentException failure) { + throw invalidImage(); + } + } + + private DomainBadRequestException invalidImage() { + return new DomainBadRequestException("error.skill.avatar.invalidImage"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java new file mode 100644 index 00000000..3dd19c16 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class SkillAvatarImageProcessorTest { + private final SkillAvatarImageProcessor processor = new SkillAvatarImageProcessor(); + + @Test + void acceptsSquareJpegAndReencodesAsPng() throws Exception { + byte[] jpeg = image("jpeg", 48, 48); + byte[] result = processor.normalize(jpeg); + assertThat(result).startsWith((byte) 0x89, (byte) 'P', (byte) 'N', (byte) 'G'); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(result)); + assertThat(decoded.getWidth()).isEqualTo(48); + assertThat(decoded.getHeight()).isEqualTo(48); + } + + @Test + void rejectsOversizedMalformedAndNonSquareInput() throws Exception { + assertThatThrownBy(() -> processor.normalize(new byte[SkillAvatarImageProcessor.MAX_BYTES + 1])) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(new byte[] {(byte) 0x89, 'P', 'N', 'G'})) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(image("png", 48, 32))) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(image("png", 1025, 1025))) + .isInstanceOf(DomainBadRequestException.class); + } + + private byte[] image(String format, int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, format, out); + return out.toByteArray(); + } +}