fix: 支持可信内部文件引用读取

- 通过服务端文件记录和存储配置恢复可信物理读取句柄

- 文档解析与工作流文档节点优先读取已确认的内部存储对象

- 补齐路径校验、记录异常和读取边界测试
This commit is contained in:
2026-08-29 15:42:07 +08:00
parent 1c68e3582c
commit 4823b0741f
9 changed files with 516 additions and 12 deletions

View File

@@ -11,6 +11,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -151,6 +152,19 @@ public class FileStorageManager implements FileStorageService {
return serviceForHandle(handle).readRecoverable(handle);
}
/**
* 使用当前后端解析服务端可信文件引用。
*
* @param reference 文件 URL 或其他后端可识别引用
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录无法安全解析时抛出
*/
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
return currentService().resolveTrustedFile(reference);
}
/**
* 严格按句柄中的后端精确删除物理对象。
*

View File

@@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
/**
* EasyFlow 文件存储统一接口。
@@ -105,6 +106,21 @@ public interface FileStorageService {
throw unsupportedRecoverableOperation("readRecoverable");
}
/**
* 将服务端可信文件引用解析为物理读取句柄。
*
* <p>实现必须以服务端持久化记录或存储平台配置为信任来源,并要求外部引用与可信来源
* 精确匹配;不得仅根据客户端传入的 URL、路径或 locator 构造句柄。</p>
*
* @param reference 文件 URL 或其他后端可识别引用
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录损坏或存储配置不兼容时抛出
*/
default Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
return Optional.empty();
}
/**
* 精确且幂等地删除句柄对应的物理对象。
*

View File

@@ -21,6 +21,7 @@ import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.Optional;
/**
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
@@ -268,6 +269,47 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
}
/**
* 使用 x-file-storage 文件记录或服务端平台配置恢复可信物理读取句柄。
*
* <p>优先使用 recorder 的精确记录。记录不存在时,仅允许与已配置平台 domain、basePath
* 及重建后的完整 URL 完全一致的引用。其他 URL 返回空,由上层继续执行公网地址安全校验。</p>
*
* @param reference 文件 URL
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录损坏或平台配置不兼容时抛出
*/
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
if (!StringUtils.hasText(reference)) {
return Optional.empty();
}
FileInfo fileInfo = null;
try {
fileInfo = fileStorageService.getFileInfoByUrl(reference);
} catch (RuntimeException exception) {
// 存储平台配置本身仍可提供精确可信边界,记录器异常不应阻断内部对象读取。
LOG.warn("查询 x-file-storage 文件记录失败继续按服务端存储配置识别reference={}",
reference, exception);
}
if (fileInfo != null) {
if (!reference.equals(fileInfo.getUrl())) {
throw new IOException("x-file-storage 文件记录 URL 与请求引用不一致");
}
try {
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
FileStorage storage = requireStorage(handle);
requirePersistedBasePathSupport(storage, handle);
verifyRecordedLocation(fileInfo, handle);
return Optional.of(handle);
} catch (RuntimeException exception) {
throw new IOException("x-file-storage 文件记录无法恢复为安全读取位置", exception);
}
}
return resolveConfiguredStorageReference(reference);
}
/**
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
*
@@ -391,6 +433,108 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
}
/**
* 校验 recorder 中的物理定位字段可由恢复句柄无损重建。
*
* @param fileInfo 服务端文件记录
* @param handle 恢复出的物理读取句柄
*/
private void verifyRecordedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) {
String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath();
if (!handle.getPlatform().equals(fileInfo.getPlatform())
|| !handle.getBasePath().equals(actualBasePath)
|| !physicalPath(handle).equals(actualPath)
|| !handle.getFilename().equals(fileInfo.getFilename())) {
throw new IllegalStateException("x-file-storage 文件记录包含非规范物理位置");
}
}
/**
* 从 FileInfo 的物理定位字段构造严格校验的读取句柄。
*
* @param fileInfo 服务端文件信息
* @return 可信物理读取句柄
*/
private FileStorageWriteHandle handleFromFileInfo(FileInfo fileInfo) {
String basePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
return new FileStorageWriteHandle(
RECOVERABLE_BACKEND,
fileInfo.getPlatform(),
basePath,
recordedRelativePath(basePath, fileInfo.getPath()),
fileInfo.getFilename());
}
/**
* 按服务端配置的平台 domain 与 basePath 识别内部存储 URL。
*
* <p>解析后会再次通过平台自身的 getFileKey 重建完整 URL 并进行精确比较,避免仅凭
* host 或字符串前缀放行其他私网目标。</p>
*
* @param reference 待识别 URL
* @return 精确匹配配置的读取句柄;不匹配任何平台时为空
* @throws IOException 匹配平台前缀但路径无法安全恢复时抛出
*/
private Optional<FileStorageWriteHandle> resolveConfiguredStorageReference(
String reference) throws IOException {
if (fileStorageService.getFileStorageList() == null) {
return Optional.empty();
}
for (FileStorage storage : fileStorageService.getFileStorageList()) {
String domain = readDomainBestEffort(storage);
if (!StringUtils.hasText(domain)) {
continue;
}
String basePath = readRequiredBasePath(storage);
String prefix = domain + basePath;
if (!reference.startsWith(prefix)) {
continue;
}
try {
String remainder = reference.substring(prefix.length());
int filenameIndex = remainder.lastIndexOf('/') + 1;
FileInfo fileInfo = new FileInfo()
.setUrl(reference)
.setPlatform(storage.getPlatform())
.setBasePath(basePath)
.setPath(remainder.substring(0, filenameIndex))
.setFilename(remainder.substring(filenameIndex));
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
requirePersistedBasePathSupport(storage, handle);
verifyRecordedLocation(fileInfo, handle);
if (!reference.equals(deriveUrlBestEffort(storage, toFileInfo(handle)))) {
throw new IllegalArgumentException("重建 URL 与请求引用不一致");
}
return Optional.of(handle);
} catch (RuntimeException exception) {
throw new IOException("服务端存储 URL 无法恢复为安全读取位置", exception);
}
}
return Optional.empty();
}
/**
* 将 recorder 保存的 x-file-storage 物理路径还原为句柄相对路径。
*
* @param basePath 平台基础路径
* @param recordedPath recorder 中保存的物理目录
* @return 不带前导斜杠的相对目录
*/
private String recordedRelativePath(String basePath, String recordedPath) {
String path = recordedPath == null ? "" : recordedPath;
if (basePath.isEmpty() || basePath.endsWith("/")) {
if (path.startsWith("/")) {
throw new IllegalArgumentException("文件记录路径与平台基础路径格式不一致");
}
return path;
}
if (!path.startsWith("/")) {
throw new IllegalArgumentException("文件记录路径缺少必要的前导斜杠");
}
return path.substring(1);
}
/**
* 构造仅包含精确物理定位字段的 FileInfo。
*
@@ -487,16 +631,26 @@ public class XFIleStorageServiceImpl implements FileStorageService {
* @return 可推导 URL平台不支持时返回 null
*/
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
String domain = readDomainBestEffort(storage);
if (domain == null) {
return null;
}
return domain + storage.getFileKey(fileInfo);
}
/**
* 使用平台公开的 getDomain 方法读取文件访问域名。
*
* @param storage 具体平台存储
* @return 平台访问域名;平台不支持时返回 null
*/
private String readDomainBestEffort(FileStorage storage) {
try {
Method method = storage.getClass().getMethod("getDomain");
if (!String.class.equals(method.getReturnType())) {
return null;
}
String domain = (String) method.invoke(storage);
if (domain == null) {
return null;
}
return domain + storage.getFileKey(fileInfo);
return (String) method.invoke(storage);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
return null;

View File

@@ -7,6 +7,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
@@ -49,6 +50,27 @@ public class FileStorageManagerTest {
assertFalse(exists);
}
/**
* 验证服务端文件记录解析使用当前配置的具体存储后端。
*
* @throws IOException 文件记录解析失败时抛出
*/
@Test
public void recordedFileResolutionUsesCurrentBackend() throws IOException {
RecordingStorage local = new RecordingStorage("local");
RecordingStorage xFile = new RecordingStorage("xFileStorage");
FileStorageManager manager = new FileStorageManager(
() -> "xFileStorage",
backend -> Map.of("local", local, "xFileStorage", xFile).get(backend));
Optional<FileStorageWriteHandle> resolved = manager.resolveTrustedFile(
"http://127.0.0.1:39000/easyflow/attachment/demo.pdf");
assertSame(xFile.recordedHandle, resolved.orElseThrow());
assertEquals(1, xFile.resolveCalls);
assertEquals(0, local.resolveCalls);
}
/**
* 可记录可恢复调用的存储测试替身。
*/
@@ -57,6 +79,8 @@ public class FileStorageManagerTest {
private final String backend;
/** 固定结果。 */
private final FileStorageWriteResult result;
/** 固定服务端文件记录句柄。 */
private final FileStorageWriteHandle recordedHandle;
/** 固定可恢复读取流。 */
private final InputStream recoverableInput = InputStream.nullInputStream();
/** prepare 调用次数。 */
@@ -69,6 +93,8 @@ public class FileStorageManagerTest {
private int deleteCalls;
/** exists 调用次数。 */
private int existsCalls;
/** 服务端文件记录解析调用次数。 */
private int resolveCalls;
/**
* 创建指定名称的存储替身。
@@ -80,6 +106,8 @@ public class FileStorageManagerTest {
FileStorageWriteHandle handle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
this.recordedHandle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "attachment", "demo.pdf");
}
/** {@inheritDoc} */
@@ -114,6 +142,13 @@ public class FileStorageManagerTest {
return recoverableInput;
}
/** {@inheritDoc} */
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
resolveCalls++;
return Optional.of(recordedHandle);
}
/** {@inheritDoc} */
@Override
public void deleteRecoverable(FileStorageWriteHandle handle) {

View File

@@ -212,6 +212,123 @@ public class XFIleStorageServiceImplTest {
client.lastArgs.object());
}
/**
* 验证 recorder 登记的回环地址附件可恢复为可信句柄并通过 MinIO 客户端直读。
*
* @throws Exception 测试替身配置或流读取失败
*/
@Test
public void recordedLoopbackUrlUsesExactMinioObject() throws Exception {
byte[] content = "workflow-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
RecordingMinioClient client = new RecordingMinioClient(content);
MinioFileStorage platform = new MinioFileStorage();
platform.setPlatform("minio-main");
platform.setBucketName("easyflow");
platform.setBasePath("attachment");
platform.setDomain("http://127.0.0.1:39000/easyflow/");
platform.setClientFactory(new FixedMinioClientFactory(client));
RecoverableStorageService delegate = new RecoverableStorageService(platform);
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
+ "d6186b17-4ab7-4f99-9299-b19df7ff0a3b/投标文件否决(废标)违规事项汇总.pdf";
delegate.recordedFileInfo = new FileInfo()
.setUrl(fileUrl)
.setPlatform("minio-main")
.setBasePath("attachment")
.setPath("/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/")
.setFilename("投标文件否决(废标)违规事项汇总.pdf");
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
byte[] actual;
try (InputStream inputStream = service.readRecoverable(handle)) {
actual = inputStream.readAllBytes();
}
assertEquals("attachment", handle.getBasePath());
assertEquals(
"1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/",
handle.getPath());
assertArrayEquals(content, actual);
assertEquals("easyflow", client.lastArgs.bucket());
assertEquals(
"attachment/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/"
+ "投标文件否决(废标)违规事项汇总.pdf",
client.lastArgs.object());
}
/**
* 验证 recorder 没有记录时,服务端配置的存储 URL 仍可通过 MinIO 客户端直读。
*
* @throws Exception 测试替身配置或流读取失败
*/
@Test
public void configuredStorageUrlWithoutRecorderUsesExactMinioObject() throws Exception {
byte[] content = "configured-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
RecordingMinioClient client = new RecordingMinioClient(content);
MinioFileStorage platform = new MinioFileStorage();
platform.setPlatform("minio-main");
platform.setBucketName("easyflow");
platform.setBasePath("attachment");
platform.setDomain("http://127.0.0.1:39000/easyflow/");
platform.setClientFactory(new FixedMinioClientFactory(client));
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
+ "0f0db465-7fa0-46b1-9fae-4f5a8c85f881/投标文件否决(废标)违规事项汇总.pdf";
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
byte[] actual;
try (InputStream inputStream = service.readRecoverable(handle)) {
actual = inputStream.readAllBytes();
}
assertArrayEquals(content, actual);
assertEquals(
"attachment/1/2026/8/26/0f0db465-7fa0-46b1-9fae-4f5a8c85f881/"
+ "投标文件否决(废标)违规事项汇总.pdf",
client.lastArgs.object());
}
/**
* 验证未配置为存储地址的回环 URL 不会被识别为可信附件。
*
* @throws Exception 测试替身注入失败
*/
@Test
public void unconfiguredLoopbackUrlIsNotResolved() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform(
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
assertTrue(service.resolveTrustedFile(
"http://127.0.0.1:39000/other/unconfigured.pdf").isEmpty());
}
/**
* 验证 recorder 中非规范物理路径会失败关闭。
*
* @throws Exception 测试替身注入失败
*/
@Test
public void corruptedRecordedLocationIsRejected() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform(
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
RecoverableStorageService delegate = new RecoverableStorageService(platform);
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/demo.pdf";
delegate.recordedFileInfo = new FileInfo()
.setUrl(fileUrl)
.setPlatform("minio-main")
.setBasePath("attachment")
.setPath("missing-leading-slash/")
.setFilename("demo.pdf");
XFIleStorageServiceImpl service = createService(delegate);
IOException exception = assertThrows(
IOException.class,
() -> service.resolveTrustedFile(fileUrl));
assertTrue(exception.getMessage().contains("无法恢复"));
}
/**
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
*
@@ -471,6 +588,8 @@ public class XFIleStorageServiceImplTest {
private int recorderDeleteCalls;
/** recorder 删除是否抛出异常。 */
private boolean recorderDeleteThrows;
/** recorder 返回的服务端文件记录。 */
private FileInfo recordedFileInfo;
/**
* 创建聚合服务替身。
@@ -479,10 +598,17 @@ public class XFIleStorageServiceImplTest {
*/
private RecoverableStorageService(FileStorage platform) {
this.platform = platform;
setFileStorageList(new java.util.concurrent.CopyOnWriteArrayList<>(
java.util.List.of(platform)));
setFileRecorder(new FileRecorder() {
@Override public boolean save(FileInfo fileInfo) { return true; }
@Override public void update(FileInfo fileInfo) { }
@Override public FileInfo getByUrl(String url) { return null; }
@Override public FileInfo getByUrl(String url) {
return recordedFileInfo != null
&& url.equals(recordedFileInfo.getUrl())
? recordedFileInfo
: null;
}
@Override public boolean delete(String url) {
recorderDeleteCalls++;
if (recorderDeleteThrows) {
@@ -506,6 +632,15 @@ public class XFIleStorageServiceImplTest {
return platform.getPlatform().equals(name) ? (T) platform : null;
}
/** {@inheritDoc} */
@Override
public FileInfo getFileInfoByUrl(String url) {
return recordedFileInfo != null
&& url.equals(recordedFileInfo.getUrl())
? recordedFileInfo
: null;
}
/** {@inheritDoc} */
@Override
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {