You've already forked FrameTour-BE
feat(puzzle): 实现智能自动填充引擎和安全增强
- 新增拼图元素自动填充引擎 PuzzleElementFillEngine - 支持基于规则的条件匹配和数据源解析 - 实现机位数量、机位ID等多维度条件策略 - 添加 DEVICE_IMAGE、USER_AVATAR 等数据源类型支持 - 增加景区隔离校验确保模板使用安全性 - 强化图片下载安全校验,防范 SSRF 攻击 - 支持本地文件路径解析和公网 URL 安全检查 - 完善静态值数据源策略支持 localPath 配置 - 优化生成流程中 faceId 和 scenicId 的校验逻辑 - 补充相关单元测试覆盖核心功能点
This commit is contained in:
@@ -50,11 +50,12 @@ public class ImageConfig implements ElementConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// 校验图片URL格式(可选)
|
||||
if (StrUtil.isNotBlank(defaultImageUrl)) {
|
||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||
}
|
||||
// 校验图片URL
|
||||
if (StrUtil.isBlank(defaultImageUrl)) {
|
||||
throw new IllegalArgumentException("默认图片URL不能为空");
|
||||
}
|
||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.ycwl.basic.puzzle.element.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.config.ImageConfig;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
@@ -14,7 +14,12 @@ import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 图片元素实现
|
||||
@@ -25,6 +30,8 @@ import java.io.File;
|
||||
@Slf4j
|
||||
public class ImageElement extends BaseElement {
|
||||
|
||||
private static final int DOWNLOAD_TIMEOUT_MS = 5000;
|
||||
|
||||
private ImageConfig imageConfig;
|
||||
|
||||
@Override
|
||||
@@ -105,29 +112,32 @@ public class ImageElement extends BaseElement {
|
||||
* @param imageUrl 图片URL或本地文件路径
|
||||
* @return BufferedImage对象
|
||||
*/
|
||||
private BufferedImage downloadImage(String imageUrl) {
|
||||
try {
|
||||
log.debug("下载图片: url={}", imageUrl);
|
||||
|
||||
// 判断是否为本地文件路径
|
||||
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
||||
// 网络图片
|
||||
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
|
||||
return ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||
} else {
|
||||
// 本地文件
|
||||
File file = new File(imageUrl);
|
||||
if (file.exists()) {
|
||||
return ImageIO.read(file);
|
||||
} else {
|
||||
log.error("本地图片文件不存在: path={}", imageUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("图片下载失败: url={}", imageUrl, e);
|
||||
protected BufferedImage downloadImage(String imageUrl) {
|
||||
if (StrUtil.isBlank(imageUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRemoteUrl(imageUrl)) {
|
||||
if (!isSafeRemoteUrl(imageUrl)) {
|
||||
log.warn("图片URL未通过安全校验, 已拒绝下载: {}", imageUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("下载图片: url={}", imageUrl);
|
||||
byte[] imageBytes = HttpRequest.get(imageUrl)
|
||||
.timeout(DOWNLOAD_TIMEOUT_MS)
|
||||
.setFollowRedirects(false)
|
||||
.execute()
|
||||
.bodyBytes();
|
||||
return ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||
} catch (Exception e) {
|
||||
log.error("图片下载失败: url={}", imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return loadLocalImage(imageUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,4 +261,63 @@ public class ImageElement extends BaseElement {
|
||||
// 绘制到主画布
|
||||
g2d.drawImage(rounded, position.getX(), position.getY(), null);
|
||||
}
|
||||
|
||||
private boolean isRemoteUrl(String imageUrl) {
|
||||
return StrUtil.startWithIgnoreCase(imageUrl, "http://") ||
|
||||
StrUtil.startWithIgnoreCase(imageUrl, "https://");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断URL是否为安全的公网HTTP地址,避免SSRF
|
||||
*/
|
||||
protected boolean isSafeRemoteUrl(String imageUrl) {
|
||||
if (StrUtil.isBlank(imageUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
URL url = new URL(imageUrl);
|
||||
String protocol = url.getProtocol();
|
||||
if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
InetAddress address = InetAddress.getByName(url.getHost());
|
||||
if (address.isAnyLocalAddress()
|
||||
|| address.isLoopbackAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("图片URL解析失败: {}", imageUrl, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage loadLocalImage(String imageUrl) {
|
||||
try {
|
||||
Path path;
|
||||
if (StrUtil.startWithIgnoreCase(imageUrl, "file:")) {
|
||||
path = Paths.get(new URI(imageUrl));
|
||||
} else {
|
||||
path = Paths.get(imageUrl);
|
||||
}
|
||||
|
||||
if (!Files.exists(path) || !Files.isRegularFile(path)) {
|
||||
log.error("本地图片文件不存在: {}", imageUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("加载本地图片: {}", path);
|
||||
try (var inputStream = Files.newInputStream(path)) {
|
||||
return ImageIO.read(inputStream);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("本地图片加载失败: {}", imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user