feat(avatar): normalize uploaded crop on server

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 11:32:13 +08:00
parent a463efb535
commit 57477e2614
2 changed files with 123 additions and 0 deletions

View file

@ -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");
}
}

View file

@ -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();
}
}