fix:发票盖章用到底图被图片加密拦截的问题
This commit is contained in:
1 parent
9347410909
commit
be93d4ae50
6 files changed
+368
-25
No files matched your search
@@ -144,7 +144,7 @@ JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明
|
||||
| 部分 | 接入位置与行为 |
|
||||
| --- | --- |
|
||||
| Java 文件入口 | `FileAccessGuard`,`SysFileController`,`SysFileServiceImpl`;租户跳过只作用于文件查询并恢复原状态 |
|
||||
| Java 内部读取 | `RemoteFileService` 保留 `getFile` 调用,通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 `%xx`;钱包、硬件导出/日志/取证通过相同签名读取;票据 URLConnection 单独适配 |
|
||||
| Java 内部读取 | `RemoteFileService.getFile` 通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 `%xx`;钱包、硬件导出/日志/取证及发票盖章通过相同签名读取 |
|
||||
| APK | 终端 `/app-version/ad/page` 和 `/app/last-version` 返回对象使用 `package` 签名,后台列表/编辑和数据库仍为原始值;独立静态 APK 地址保持不变 |
|
||||
| 后台 | `src/utils/file-access*.ts`;`FileImage` 保留已加载画面,未加载的懒图片继续续期,打开/切换预览时取有效签名;失败重试仅刷新当前文件,不清空全局签名缓存;上传回显、素材库、头像、视频、版本复制和下载均已接入;KKFileView 在编码之前签名源地址 |
|
||||
| 后台编辑器 | 只更新编辑器 DOM 的资源属性,不替换整篇文档;输出 HTML 剥离临时签名,续期不进入编辑历史 |
|
||||
@@ -156,6 +156,28 @@ JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明
|
||||
|
||||
第三方 URL、静态资源、`data:`、`blob:`、本地临时文件不加本协议签名。复制访问链接是临时访问地址;编辑、存储文件引用使用原始值。
|
||||
|
||||
### Java 图片合成等内部读取
|
||||
|
||||
文件中心图片使用 `RemoteFileService.getFile(fileName)`,每次调用由专属 Feign 拦截器生成 `service` 签名,并通过服务发现访问 UPMS。业务字段如果保存的是完整或相对文件链接,先用 `FileAccessUrl.fileName(source)` 提取原始文件名:
|
||||
|
||||
```java
|
||||
String fileName = FileAccessUrl.fileName(source);
|
||||
if (fileName == null) throw new IllegalArgumentException("不是文件中心引用");
|
||||
try (Response response = remoteFileService.getFile(fileName)) {
|
||||
if (response.status() < 200 || response.status() >= 300 || response.body() == null) {
|
||||
throw new IOException("文件读取失败");
|
||||
}
|
||||
try (InputStream input = response.body().asInputStream()) {
|
||||
BufferedImage image = ImageIO.read(input);
|
||||
// 校验 image 非空后合成。
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
提取工具支持原始文件名、`/sys-file/oss/file`、`/admin/sys-file/oss/file` 及带网关前缀的 `/admin/sys-file/oss/file` 链接;查询中的文件名只解码一次,重复、缺失或非法文件名拒绝处理。链接中的主机和旧签名不参与内部调用,始终读取当前环境的 UPMS,不会向原链接主机发送 `service` 凭据。
|
||||
|
||||
`WalletRechargeInvoiceServiceImpl.stamp` 已按此方式读取底图。因此 `http://127.0.0.1:9999/admin/sys-file/oss/file?...`、历史域名链接和相对链接都不再依赖 `IMAGE_BASE_URL` 或公网来源列表。非文件中心图片保留原有 HTTP 读取方式,相对静态资源仍使用 `IMAGE_BASE_URL`。调用方和 UPMS 必须加载相同的 `clients.service` 活动密钥;本次修复需要重新构建并发布 Wallet 及更新后的 common-core,无需为本机地址扩大 `allowed-origins`。
|
||||
|
||||
旧终端兼容只接受完整 `Dart/<版本> (dart:io)` UA,且扩展名在 jpg/jpeg/png/gif/webp/bmp/svg/ico 内。完全没有签名参数才可走兼容分支;错误或过期签名不会回退。APK、日志、文档不凭 Dart UA 放行。
|
||||
|
||||
## 发布和回滚
|
||||
@@ -188,7 +210,7 @@ Java 定向回归:
|
||||
```bash
|
||||
mvn -pl pjcloud-upms/pjcloud-upms-biz,pjcloud-integrated/pjcloud-integrated-biz,pjcloud-wallet/pjcloud-wallet-biz,pjcloud-hardware/pjcloud-hardware-biz,pjcloud-canteen/pjcloud-canteen-biz \
|
||||
-am test \
|
||||
-Dtest=FileAccessTest,FileReadTest,FileUploadGuardTest,FileUploadPolicyTest,FileUploadControllerTest,AppVersionFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \
|
||||
-Dtest=FileAccessTest,FileReadTest,FileUploadGuardTest,FileUploadPolicyTest,FileUploadControllerTest,AppVersionFileAccessTest,FileAccessUrlTest,WalletRechargeInvoiceFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \
|
||||
-Dsurefire.failIfNoSpecifiedTests=false
|
||||
```
|
||||
|
||||
|
||||
@@ -150,3 +150,15 @@ Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。
|
||||
- 执行 `mvn -pl pjcloud-upms/pjcloud-upms-biz -am test -Dtest=FileAccessTest,FileAccessMobileTest,FileUploadGuardTest -Dsurefire.failIfNoSpecifiedTests=false`:23 项通过,0 失败、错误或跳过,Maven 构建成功。
|
||||
- 新增测试覆盖实际根 Referer、完整页面 Referer、无末尾斜杠的配置、默认/自定义端口、域名伪造、错误协议、重复/缺失 Referer、目录范围不扩大、关闭白名单、导航及非图片拒绝、上传仍需鉴权、GET/HEAD 请求到存储的边界。
|
||||
- 本机验证日志为 `/tmp/pj-file-access-origin-tests.log`。未修改线上配置或发布服务,线上恢复情况需在新版 UPMS 和对应公共依赖发布后确认。若根来源配置共享给其他启用文件配置的服务,也需先更新这些服务的公共依赖。
|
||||
|
||||
## 发票盖章内部读取修复
|
||||
|
||||
2026-10-30 反馈 `WalletRechargeInvoiceServiceImpl.stamp` 访问 `http://127.0.0.1:9999/admin/sys-file/oss/file?fileName=...` 返回 403。原实现使用 `FileAccessSigner.signUrl`,不在公网来源配置中的主机不会被签名,随后 URLConnection 发出的裸请求在严格模式被拒绝。
|
||||
|
||||
盖章现在使用 `FileAccessUrl.fileName` 解析文件引用,再由 `RemoteFileService.getFile` 通过服务发现读取 UPMS,并在请求时生成 `service` 签名。文件中心读取不再依赖 `IMAGE_BASE_URL` 或原链接的主机;文件访问入口及其鉴权规则未修改。
|
||||
|
||||
- 执行 `mvn -pl pjcloud-wallet/pjcloud-wallet-biz,pjcloud-upms/pjcloud-upms-biz -am test -Dtest=WalletRechargeInvoiceFileAccessTest,FileAccessUrlTest,FileAccessTest,FileAccessMobileTest,FileUploadGuardTest -Dsurefire.failIfNoSpecifiedTests=false`:31 项通过,0 失败、错误或跳过,Maven 构建成功。
|
||||
- 新回归覆盖本机网关、历史域名及网关前缀、相对路径、原始文件名、旧签名替换、中文/加号/百分号编码、重复与非法文件名、读取失败不上传、源流关闭、PNG 盖章合成,以及第三方图片请求保持原样。
|
||||
- 本机验证日志为 `/tmp/pj-internal-image-access-tests.log`。测试中的 Feign 文件服务为替身,未访问真实注册中心或存储;未修改 Nacos、密钥或运行中的服务。
|
||||
|
||||
需重新构建并发布 Wallet,包含更新后的 common-core;确认 Wallet 与 UPMS 的 `clients.service` 密钥一致,再在实际环境重新生成发票验收。
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.bsd.pjcloud.common.core.file;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Resolves stored file references for internal reads through RemoteFileService. */
|
||||
public final class FileAccessUrl {
|
||||
private FileAccessUrl() { }
|
||||
|
||||
/**
|
||||
* Returns the original filename, or null for a URL outside the file-center routes.
|
||||
* The source host is deliberately not used: callers must read the result from
|
||||
* their own UPMS, never sign or fetch the supplied host with service credentials.
|
||||
*/
|
||||
public static String fileName(String source) {
|
||||
if (source == null || source.isBlank()) throw new IllegalArgumentException("Empty file reference");
|
||||
String value = source.trim();
|
||||
if (!value.contains("/") && !value.contains("?") && !value.contains(":")) {
|
||||
return validateFileName(value);
|
||||
}
|
||||
URI uri = URI.create(value);
|
||||
String path = uri.getRawPath();
|
||||
if (path == null) return null;
|
||||
if (!path.startsWith("/")) path = "/" + path;
|
||||
if (!(path.equals("/sys-file/oss/file") || path.endsWith("/admin/sys-file/oss/file"))) return null;
|
||||
if ((uri.isAbsolute() && !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())))
|
||||
|| (!uri.isAbsolute() && uri.getRawAuthority() != null) || uri.getUserInfo() != null
|
||||
|| uri.getRawFragment() != null) {
|
||||
throw new IllegalArgumentException("Invalid file-center URL");
|
||||
}
|
||||
String name = null;
|
||||
if (uri.getRawQuery() != null) {
|
||||
for (String pair : uri.getRawQuery().split("&")) {
|
||||
String[] parts = pair.split("=", 2);
|
||||
if (decode(parts[0]).equals("fileName")) {
|
||||
if (name != null) throw new IllegalArgumentException("Duplicate fileName");
|
||||
name = decode(parts.length == 2 ? parts[1] : "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return validateFileName(name);
|
||||
}
|
||||
|
||||
private static String validateFileName(String name) {
|
||||
if (name == null || name.isBlank() || name.length() > 1024 || name.contains("/") || name.contains("\\")
|
||||
|| name.equals(".") || name.equals("..") || name.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException("Invalid file-center filename");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private static String decode(String value) {
|
||||
return URLDecoder.decode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+32
-23
@@ -21,6 +21,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.common.core.file.FileAccessUrl;
|
||||
import com.bsd.pjcloud.common.core.util.CollectionUtils;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
@@ -54,6 +55,7 @@ import com.bsd.pjcloud.wallet.utils.ConvertCnPriceUtil;
|
||||
import com.bsd.pjcloud.wallet.utils.flow.FlowUtil;
|
||||
import com.bsd.pjcloud.wallet.utils.flow.dto.WalletRechargeInvoiceFlowDto;
|
||||
import com.bsd.pjcloud.wallet.utils.page.PaginationOrderSqlExtractor;
|
||||
import feign.Response;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -66,6 +68,7 @@ import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
@@ -406,9 +409,6 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl<WalletRecharge
|
||||
/**
|
||||
* 生成盖章图片
|
||||
*/
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.bsd.pjcloud.common.core.file.FileAccessSigner fileAccessSigner;
|
||||
|
||||
@Override
|
||||
public String stamp(WalletRechargeInvoiceEntity invoice) {
|
||||
// 查看有没有盖章
|
||||
@@ -421,34 +421,15 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl<WalletRecharge
|
||||
log.error("|{}|盖章失败,审核成功才能开发票,invoice:|{}|", TAG, JSONUtil.toJsonStr(invoice));
|
||||
return null;
|
||||
}
|
||||
String imageBaseUrl = ParamResolver.getStr("IMAGE_BASE_URL");
|
||||
if (ObjectUtil.isEmpty(imageBaseUrl)) {
|
||||
throw new RuntimeException("请配置图片基础路径");
|
||||
}
|
||||
if (ObjectUtil.isEmpty(invoice.getPicUrl())) {
|
||||
log.error("|{}|盖章失败,发票图片为空,invoice:|{}|", TAG, JSONUtil.toJsonStr(invoice));
|
||||
return null;
|
||||
}
|
||||
// 开始盖章
|
||||
String picUrl = invoice.getPicUrl();
|
||||
if (!picUrl.startsWith("http://") && !picUrl.startsWith("https://")) {
|
||||
if (imageBaseUrl.endsWith("/") && picUrl.startsWith("/")) {
|
||||
picUrl = imageBaseUrl + picUrl.substring(1);
|
||||
} else if (!imageBaseUrl.endsWith("/") && !picUrl.startsWith("/")) {
|
||||
picUrl = imageBaseUrl + "/" + picUrl;
|
||||
} else {
|
||||
picUrl = imageBaseUrl + picUrl;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 读取底图
|
||||
URLConnection connection = new URL(fileAccessSigner.signUrl(picUrl, "service")).openConnection();
|
||||
connection.setConnectTimeout(5000);
|
||||
connection.setReadTimeout(5000);
|
||||
BufferedImage baseImage;
|
||||
try (InputStream inputStream = connection.getInputStream()) {
|
||||
baseImage = ImageIO.read(inputStream);
|
||||
}
|
||||
BufferedImage baseImage = readInvoiceImage(picUrl);
|
||||
if (ObjectUtil.isEmpty(baseImage)) {
|
||||
log.error("|{}|盖章失败,底图解析失败,picUrl:|{}|invoice:|{}|", TAG, picUrl, JSONUtil.toJsonStr(invoice));
|
||||
return null;
|
||||
@@ -515,6 +496,34 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl<WalletRecharge
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage readInvoiceImage(String source) throws IOException {
|
||||
String fileName = FileAccessUrl.fileName(source);
|
||||
if (fileName != null) {
|
||||
// Feign 自动附加 service 签名;不通过公网域名或本机网关读取文件中心图片。
|
||||
try (Response response = remoteFileService.getFile(fileName)) {
|
||||
if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) {
|
||||
throw new IOException("读取发票底图失败,文件服务状态:" + (response == null ? "无响应" : response.status()));
|
||||
}
|
||||
try (InputStream inputStream = response.body().asInputStream()) {
|
||||
return ImageIO.read(inputStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 保留非文件中心图片原有的读取方式,不向第三方地址附加服务签名。
|
||||
String url = source;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
String imageBaseUrl = ParamResolver.getStr("IMAGE_BASE_URL");
|
||||
if (ObjectUtil.isEmpty(imageBaseUrl)) throw new IOException("请配置图片基础路径");
|
||||
url = imageBaseUrl.replaceAll("/+$", "") + "/" + url.replaceAll("^/+", "");
|
||||
}
|
||||
URLConnection connection = new URL(url).openConnection();
|
||||
connection.setConnectTimeout(5000);
|
||||
connection.setReadTimeout(5000);
|
||||
try (InputStream inputStream = connection.getInputStream()) {
|
||||
return ImageIO.read(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
private static int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.bsd.pjcloud.wallet.service.impl.recharge;
|
||||
|
||||
import com.bsd.pjcloud.common.core.file.FileAccessUrl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FileAccessUrlTest {
|
||||
@Test
|
||||
void queryIsDecodedOnceWhileBareFileNamesKeepLiteralEscapes() {
|
||||
assertEquals("照片 +&%2F.png", FileAccessUrl.fileName(
|
||||
"/admin/sys-file/oss/file?fileName=%E7%85%A7%E7%89%87%20%2B%26%252F.png"));
|
||||
assertEquals("literal%2F.png", FileAccessUrl.fileName("literal%2F.png"));
|
||||
assertEquals("base.png", FileAccessUrl.fileName(
|
||||
"http://127.0.0.1:9999/api/admin/sys-file/oss/file?%66ileName=base.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherImageUrlsAreNotTreatedAsFileCenterObjects() {
|
||||
for (String source : List.of("https://other.example/static/a.png", "/static/a.png",
|
||||
"https://other.example/not-admin/sys-file/oss/file?fileName=a.png",
|
||||
"https://other.example/admin/sys-file/oss/file-extra?fileName=a.png")) {
|
||||
assertNull(FileAccessUrl.fileName(source), source);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ambiguousMalformedOrUnsafeFileNamesAreRejected() {
|
||||
for (String source : List.of("", "..", "bad\\name.png", "/admin/sys-file/oss/file",
|
||||
"/admin/sys-file/oss/file?fileName=a.png&%66ileName=b.png",
|
||||
"/admin/sys-file/oss/file?fileName=%ZZ",
|
||||
"/admin/sys-file/oss/file?fileName=a%0Ab.png",
|
||||
"/admin/sys-file/oss/file?fileName=dir%2Fa.png",
|
||||
"/admin/sys-file/oss/file?fileName=dir%5Ca.png",
|
||||
"https://user@other.example/admin/sys-file/oss/file?fileName=a.png",
|
||||
"//other.example/admin/sys-file/oss/file?fileName=a.png",
|
||||
"/admin/sys-file/oss/file?fileName=a.png#fragment")) {
|
||||
assertThrows(IllegalArgumentException.class, () -> FileAccessUrl.fileName(source), source);
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package com.bsd.pjcloud.wallet.service.impl.recharge;
|
||||
|
||||
import com.bsd.pjcloud.admin.api.config.FileAccessFeignConfiguration;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
|
||||
import com.bsd.pjcloud.common.core.file.FileAccessProperties;
|
||||
import com.bsd.pjcloud.common.core.file.FileAccessSignature;
|
||||
import com.bsd.pjcloud.common.core.file.FileAccessSigner;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum;
|
||||
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import feign.Feign;
|
||||
import feign.Request;
|
||||
import feign.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.cloud.openfeign.support.SpringMvcContract;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class WalletRechargeInvoiceFileAccessTest {
|
||||
private static final String STAMPED_URL = "/admin/sys-file/oss/file?fileName=stamped.png";
|
||||
private final RemoteFileService files = mock(RemoteFileService.class);
|
||||
private final WalletRechargeInvoiceServiceImpl service = new WalletRechargeInvoiceServiceImpl(
|
||||
null, null, null, null, null, files, null, null);
|
||||
|
||||
@Test
|
||||
void localGatewayAndStoredReferencesUseFreshServiceSignaturesWithoutPublicOriginConfiguration() throws Exception {
|
||||
long now = 1_800_000_000L;
|
||||
String key = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8));
|
||||
var properties = new FileAccessProperties();
|
||||
properties.setPublicBaseUrl("https://public.example/api");
|
||||
var client = new FileAccessProperties.Client();
|
||||
client.setKeys(Map.of("v1", key));
|
||||
properties.setClients(Map.of("service", client));
|
||||
var signer = new FileAccessSigner(properties, Clock.fixed(Instant.ofEpochSecond(now), ZoneOffset.UTC));
|
||||
byte[] image = image();
|
||||
var captured = new AtomicReference<Request>();
|
||||
RemoteFileService signedClient = Feign.builder().contract(new SpringMvcContract())
|
||||
.requestInterceptor(new FileAccessFeignConfiguration().fileAccessRequestInterceptor(signer))
|
||||
.client((request, options) -> {
|
||||
captured.set(request);
|
||||
return Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(image).build();
|
||||
}).target(RemoteFileService.class, "http://upms-internal");
|
||||
when(files.getFile(anyString())).thenAnswer(call -> signedClient.getFile(call.getArgument(0)));
|
||||
when(files.innerUpload(any(), isNull(), isNull(), isNull())).thenReturn(R.ok(Map.of("url", STAMPED_URL)));
|
||||
String name = "发票 +&%2F.png";
|
||||
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
|
||||
for (String source : List.of(
|
||||
"http://127.0.0.1:9999/admin/sys-file/oss/file?fileName=" + encoded,
|
||||
"/admin/sys-file/oss/file?fileName=" + encoded,
|
||||
"admin/sys-file/oss/file?fileName=" + encoded,
|
||||
"/api/admin/sys-file/oss/file?fileName=" + encoded,
|
||||
"/sys-file/oss/file?fileName=" + encoded,
|
||||
"https://old.example/gateway/api/admin/sys-file/oss/file?appId=admin&kid=old&ts=1&sign=expired&fileName=" + encoded,
|
||||
name)) {
|
||||
assertEquals(STAMPED_URL, service.stamp(invoice(source)), source);
|
||||
URI request = URI.create(captured.get().url());
|
||||
assertEquals("upms-internal", request.getHost());
|
||||
assertEquals("/sys-file/oss/file", request.getPath());
|
||||
Map<String, String> params = query(request);
|
||||
assertEquals(name, params.get("fileName"));
|
||||
assertEquals("service", params.get("appId"));
|
||||
assertEquals("v1", params.get("kid"));
|
||||
assertEquals(Long.toString(now), params.get("ts"));
|
||||
assertEquals(FileAccessSignature.sign(name, "service", "v1", now, key), params.get("sign"));
|
||||
}
|
||||
verify(files, times(7)).getFile(name);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsAndClosesSourceBeforeUploadingACompositedImage() throws Exception {
|
||||
byte[] image = image();
|
||||
var closed = new AtomicBoolean();
|
||||
var input = new ByteArrayInputStream(image) {
|
||||
@Override public void close() throws IOException { closed.set(true); super.close(); }
|
||||
};
|
||||
when(files.getFile("base.png")).thenReturn(response(200).body(input, image.length).build());
|
||||
when(files.innerUpload(any(), isNull(), isNull(), isNull())).thenAnswer(call -> {
|
||||
assertTrue(closed.get());
|
||||
MultipartFile upload = call.getArgument(0);
|
||||
assertEquals("image/png", upload.getContentType());
|
||||
BufferedImage stamped = ImageIO.read(upload.getInputStream());
|
||||
assertNotNull(stamped);
|
||||
assertEquals(700, stamped.getWidth());
|
||||
assertEquals(400, stamped.getHeight());
|
||||
assertTrue(java.util.Arrays.stream(stamped.getRGB(0, 0, 700, 400, null, 0, 700))
|
||||
.anyMatch(pixel -> pixel != Color.WHITE.getRGB()));
|
||||
return R.ok(Map.of("url", STAMPED_URL));
|
||||
});
|
||||
|
||||
assertEquals(STAMPED_URL, service.stamp(invoice("/admin/sys-file/oss/file?fileName=base.png")));
|
||||
verify(files).innerUpload(any(), isNull(), isNull(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedMissingAndNonImageResponsesNeverUploadAStampedResult() {
|
||||
for (Response response : List.of(
|
||||
response(403).body("forbidden", StandardCharsets.UTF_8).build(),
|
||||
response(404).body(new byte[0]).build(),
|
||||
response(500).body(new byte[0]).build(),
|
||||
response(200).build(),
|
||||
response(200).body("not an image", StandardCharsets.UTF_8).build())) {
|
||||
when(files.getFile("base.png")).thenReturn(response);
|
||||
assertNull(service.stamp(invoice("/admin/sys-file/oss/file?fileName=base.png")));
|
||||
}
|
||||
verify(files, never()).innerUpload(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedFileCenterReferencesFailBeforeAnyRemoteReadOrUpload() {
|
||||
for (String source : List.of(
|
||||
"/admin/sys-file/oss/file?fileName=one.png&fileName=two.png",
|
||||
"/admin/sys-file/oss/file?fileName=",
|
||||
"/admin/sys-file/oss/file?fileName=..%2Fsecret.png",
|
||||
"file:/admin/sys-file/oss/file?fileName=base.png")) {
|
||||
assertNull(service.stamp(invoice(source)));
|
||||
}
|
||||
verifyZeroInteractions(files);
|
||||
}
|
||||
|
||||
@Test
|
||||
void thirdPartyImagesKeepTheirOriginalHttpRequestWithoutServiceCredentials() throws Exception {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
var sourceRequest = new AtomicReference<URI>();
|
||||
byte[] image = image();
|
||||
server.createContext("/external.png", exchange -> {
|
||||
sourceRequest.set(exchange.getRequestURI());
|
||||
exchange.sendResponseHeaders(200, image.length);
|
||||
try (var output = exchange.getResponseBody()) { output.write(image); }
|
||||
});
|
||||
server.start();
|
||||
try {
|
||||
when(files.innerUpload(any(), isNull(), isNull(), isNull())).thenReturn(R.ok(Map.of("url", STAMPED_URL)));
|
||||
String source = "http://127.0.0.1:" + server.getAddress().getPort() + "/external.png?version=1";
|
||||
assertEquals(STAMPED_URL, service.stamp(invoice(source)));
|
||||
assertEquals("/external.png?version=1", sourceRequest.get().toString());
|
||||
verify(files, never()).getFile(anyString());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private WalletRechargeInvoiceEntity invoice(String source) {
|
||||
var invoice = new WalletRechargeInvoiceEntity();
|
||||
invoice.setId(1L);
|
||||
invoice.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
|
||||
invoice.setPicUrl(source);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private Response.Builder response(int status) {
|
||||
return Response.builder().request(Request.create(Request.HttpMethod.GET, "http://upms-internal/sys-file/oss/file",
|
||||
Map.of(), null, StandardCharsets.UTF_8, null)).status(status).reason("test").headers(Map.of());
|
||||
}
|
||||
|
||||
private byte[] image() throws IOException {
|
||||
BufferedImage image = new BufferedImage(700, 400, BufferedImage.TYPE_INT_RGB);
|
||||
var graphics = image.createGraphics();
|
||||
graphics.setColor(Color.WHITE);
|
||||
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
|
||||
graphics.dispose();
|
||||
try (var output = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(image, "PNG", output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> query(URI uri) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
for (String pair : uri.getRawQuery().split("&")) {
|
||||
String[] parts = pair.split("=", 2);
|
||||
assertNull(values.put(parts[0], URLDecoder.decode(parts[1], StandardCharsets.UTF_8)));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user