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

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

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

- 补齐路径校验、记录异常和读取边界测试
This commit is contained in:
2026-08-29 15:42:07 +08:00
parent 7aed4bcc37
commit 7e1490d5f8
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.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
@@ -151,6 +152,19 @@ public class FileStorageManager implements FileStorageService {
return serviceForHandle(handle).readRecoverable(handle); 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.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Optional;
/** /**
* EasyFlow 文件存储统一接口。 * EasyFlow 文件存储统一接口。
@@ -105,6 +106,21 @@ public interface FileStorageService {
throw unsupportedRecoverableOperation("readRecoverable"); 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.InvocationTargetException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
/** /**
* 基于 x-file-storage 的 EasyFlow 文件存储实现。 * 基于 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 记录的聚合删除路径。 * 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 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。 * 构造仅包含精确物理定位字段的 FileInfo。
* *
@@ -487,16 +631,26 @@ public class XFIleStorageServiceImpl implements FileStorageService {
* @return 可推导 URL平台不支持时返回 null * @return 可推导 URL平台不支持时返回 null
*/ */
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) { 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 { try {
Method method = storage.getClass().getMethod("getDomain"); Method method = storage.getClass().getMethod("getDomain");
if (!String.class.equals(method.getReturnType())) { if (!String.class.equals(method.getReturnType())) {
return null; return null;
} }
String domain = (String) method.invoke(storage); return (String) method.invoke(storage);
if (domain == null) {
return null;
}
return domain + storage.getFileKey(fileInfo);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) { } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName()); LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
return null; return null;

View File

@@ -7,6 +7,7 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -49,6 +50,27 @@ public class FileStorageManagerTest {
assertFalse(exists); 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 String backend;
/** 固定结果。 */ /** 固定结果。 */
private final FileStorageWriteResult result; private final FileStorageWriteResult result;
/** 固定服务端文件记录句柄。 */
private final FileStorageWriteHandle recordedHandle;
/** 固定可恢复读取流。 */ /** 固定可恢复读取流。 */
private final InputStream recoverableInput = InputStream.nullInputStream(); private final InputStream recoverableInput = InputStream.nullInputStream();
/** prepare 调用次数。 */ /** prepare 调用次数。 */
@@ -69,6 +93,8 @@ public class FileStorageManagerTest {
private int deleteCalls; private int deleteCalls;
/** exists 调用次数。 */ /** exists 调用次数。 */
private int existsCalls; private int existsCalls;
/** 服务端文件记录解析调用次数。 */
private int resolveCalls;
/** /**
* 创建指定名称的存储替身。 * 创建指定名称的存储替身。
@@ -80,6 +106,8 @@ public class FileStorageManagerTest {
FileStorageWriteHandle handle = new FileStorageWriteHandle( FileStorageWriteHandle handle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "skill-content", "content.bin"); backend, "", "/tmp/easyflow", "skill-content", "content.bin");
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator()); this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
this.recordedHandle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "attachment", "demo.pdf");
} }
/** {@inheritDoc} */ /** {@inheritDoc} */
@@ -114,6 +142,13 @@ public class FileStorageManagerTest {
return recoverableInput; return recoverableInput;
} }
/** {@inheritDoc} */
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
resolveCalls++;
return Optional.of(recordedHandle);
}
/** {@inheritDoc} */ /** {@inheritDoc} */
@Override @Override
public void deleteRecoverable(FileStorageWriteHandle handle) { public void deleteRecoverable(FileStorageWriteHandle handle) {

View File

@@ -212,6 +212,123 @@ public class XFIleStorageServiceImplTest {
client.lastArgs.object()); 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 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。 * 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
* *
@@ -471,6 +588,8 @@ public class XFIleStorageServiceImplTest {
private int recorderDeleteCalls; private int recorderDeleteCalls;
/** recorder 删除是否抛出异常。 */ /** recorder 删除是否抛出异常。 */
private boolean recorderDeleteThrows; private boolean recorderDeleteThrows;
/** recorder 返回的服务端文件记录。 */
private FileInfo recordedFileInfo;
/** /**
* 创建聚合服务替身。 * 创建聚合服务替身。
@@ -479,10 +598,17 @@ public class XFIleStorageServiceImplTest {
*/ */
private RecoverableStorageService(FileStorage platform) { private RecoverableStorageService(FileStorage platform) {
this.platform = platform; this.platform = platform;
setFileStorageList(new java.util.concurrent.CopyOnWriteArrayList<>(
java.util.List.of(platform)));
setFileRecorder(new FileRecorder() { setFileRecorder(new FileRecorder() {
@Override public boolean save(FileInfo fileInfo) { return true; } @Override public boolean save(FileInfo fileInfo) { return true; }
@Override public void update(FileInfo fileInfo) { } @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) { @Override public boolean delete(String url) {
recorderDeleteCalls++; recorderDeleteCalls++;
if (recorderDeleteThrows) { if (recorderDeleteThrows) {
@@ -506,6 +632,15 @@ public class XFIleStorageServiceImplTest {
return platform.getPlatform().equals(name) ? (T) platform : null; 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} */ /** {@inheritDoc} */
@Override @Override
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) { public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {

View File

@@ -10,6 +10,7 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentSourceRef; import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader; import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil; import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
import java.io.IOException; import java.io.IOException;
@@ -164,7 +165,7 @@ public class DocumentSourceLoader {
} }
/** /**
* 优先打开经过上传记录验证的受管 URL再执行普通公网 URL 校验与下载。 * 优先打开经过上传记录验证的受管 URL 和服务端已登记附件,再执行普通公网 URL 校验与下载。
* *
* @param remoteUrl 远端 URL * @param remoteUrl 远端 URL
* @param maxBytes 最大允许读取字节数 * @param maxBytes 最大允许读取字节数
@@ -176,6 +177,13 @@ public class DocumentSourceLoader {
if (managed.isPresent()) { if (managed.isPresent()) {
return DocumentInputStreamSupport.limit(managed.get(), maxBytes); return DocumentInputStreamSupport.limit(managed.get(), maxBytes);
} }
Optional<FileStorageWriteHandle> trusted =
fileStorageService.resolveTrustedFile(remoteUrl);
if (trusted.isPresent()) {
return DocumentInputStreamSupport.limit(
fileStorageService.readRecoverable(trusted.get()),
maxBytes);
}
return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes); return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes);
} }

View File

@@ -15,6 +15,7 @@ import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
import tech.easyflow.ai.document.support.DocumentParseSourceType; import tech.easyflow.ai.document.support.DocumentParseSourceType;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader; import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.util.StringUtil; import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
@@ -326,20 +327,29 @@ public class DocNodeFileContentExtractor {
private void copySourceToTemporaryFile( private void copySourceToTemporaryFile(
DocumentSourceRef sourceRef, Path target) throws IOException { DocumentSourceRef sourceRef, Path target) throws IOException {
String filePath = sourceRef.getFilePath(); String filePath = sourceRef.getFilePath();
boolean managedUpload = StringUtil.hasText(filePath)
&& uploadedFileReader != null
&& uploadedFileReader.isManagedPathCandidate(filePath);
Optional<FileStorageWriteHandle> trustedFile = Optional.empty();
if (StringUtil.hasText(filePath)
&& isRemoteUrl(filePath)
&& !managedUpload) {
trustedFile = fileStorageService.resolveTrustedFile(filePath);
}
boolean localStorage = StringUtil.hasText(filePath) boolean localStorage = StringUtil.hasText(filePath)
&& (!isRemoteUrl(filePath) && (!isRemoteUrl(filePath)
|| (uploadedFileReader != null || managedUpload
&& uploadedFileReader.isManagedPathCandidate(filePath))); || trustedFile.isPresent());
if (localStorage) { if (localStorage) {
try (IoBulkhead.Permit ignored = try (IoBulkhead.Permit ignored =
IoBulkhead.storage().acquire("storage:document-read"); IoBulkhead.storage().acquire("storage:document-read");
InputStream inputStream = openInputStream(sourceRef); InputStream inputStream = openInputStream(sourceRef, trustedFile);
OutputStream outputStream = Files.newOutputStream(target)) { OutputStream outputStream = Files.newOutputStream(target)) {
copy(inputStream, outputStream); copy(inputStream, outputStream);
} }
return; return;
} }
try (InputStream inputStream = openInputStream(sourceRef); try (InputStream inputStream = openInputStream(sourceRef, trustedFile);
OutputStream outputStream = Files.newOutputStream(target)) { OutputStream outputStream = Files.newOutputStream(target)) {
copy(inputStream, outputStream); copy(inputStream, outputStream);
} }
@@ -362,7 +372,17 @@ public class DocNodeFileContentExtractor {
} }
} }
private InputStream openInputStream(DocumentSourceRef sourceRef) throws IOException { /**
* 按可信受管上传、服务端文件记录、本地路径和普通公网 URL 的顺序打开源流。
*
* @param sourceRef 文档源
* @param trustedFile 已通过服务端记录或存储配置确认的物理读取句柄
* @return 受实际字节数限制的输入流
* @throws IOException 文件无法安全读取时抛出
*/
private InputStream openInputStream(
DocumentSourceRef sourceRef,
Optional<FileStorageWriteHandle> trustedFile) throws IOException {
String filePath = sourceRef.getFilePath(); String filePath = sourceRef.getFilePath();
if (uploadedFileReader != null && StringUtil.hasText(filePath)) { if (uploadedFileReader != null && StringUtil.hasText(filePath)) {
Optional<InputStream> managed = uploadedFileReader.openVerified(filePath); Optional<InputStream> managed = uploadedFileReader.openVerified(filePath);
@@ -372,6 +392,11 @@ public class DocNodeFileContentExtractor {
FILE_MAX_SINGLE_SIZE); FILE_MAX_SINGLE_SIZE);
} }
} }
if (trustedFile.isPresent()) {
return DocumentInputStreamSupport.limit(
fileStorageService.readRecoverable(trustedFile.get()),
FILE_MAX_SINGLE_SIZE);
}
if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) { if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) {
return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE); return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE);
} }

View File

@@ -7,6 +7,7 @@ import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException; import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentSourceRef; import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
@@ -73,6 +74,24 @@ public class DocumentSourceLoaderTest {
} }
} }
/**
* 验证服务端已登记的普通附件 URL 在公网地址校验前通过物理句柄直读。
*/
@Test
public void shouldLoadRecordedInternalStorageUrlBeforeRemoteAddressGuard() {
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/demo.pdf";
byte[] body = "recorded-pdf".getBytes(StandardCharsets.UTF_8);
DocumentSourceLoader loader = new DocumentSourceLoader(
new RecordedFileStorageService(fileUrl, body));
DocumentSourceRef sourceRef = new DocumentSourceRef();
sourceRef.setFileName("demo.pdf");
sourceRef.setFilePath(fileUrl);
LoadedDocumentSource loadedSource = loader.load(sourceRef);
Assert.assertArrayEquals(body, loadedSource.getContentBytes());
}
/** /**
* 验证已通过上传记录校验的内网存储 URL 会走恢复句柄读取。 * 验证已通过上传记录校验的内网存储 URL 会走恢复句柄读取。
* *
@@ -217,4 +236,42 @@ public class DocumentSourceLoaderTest {
return 0L; return 0L;
} }
} }
/**
* 仅允许通过服务端记录句柄读取内容的存储测试替身。
*/
private static class RecordedFileStorageService
extends FailingFileStorageService {
/** 允许解析的精确 URL。 */
private final String recordedUrl;
/** 固定文件内容。 */
private final byte[] content;
/** 固定可信读取句柄。 */
private final FileStorageWriteHandle handle = new FileStorageWriteHandle(
"recorded", "", "/storage", "attachment", "demo.pdf");
/**
* 创建服务端记录存储替身。
*
* @param recordedUrl 允许解析的精确 URL
* @param content 固定文件内容
*/
private RecordedFileStorageService(String recordedUrl, byte[] content) {
this.recordedUrl = recordedUrl;
this.content = content.clone();
}
/** {@inheritDoc} */
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
return recordedUrl.equals(reference) ? Optional.of(handle) : Optional.empty();
}
/** {@inheritDoc} */
@Override
public InputStream readRecoverable(FileStorageWriteHandle requestedHandle) {
Assert.assertSame(handle, requestedHandle);
return new ByteArrayInputStream(content);
}
}
} }

View File

@@ -10,6 +10,7 @@ import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef; import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.service.DocumentParseBridgeService; import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -203,6 +204,27 @@ public class DocNodeFileContentExtractorTest {
Assert.assertNull(bridgeService.lastSource); Assert.assertNull(bridgeService.lastSource);
} }
/**
* 验证默认读取器也能通过服务端文件记录读取内部附件 URL。
*/
@Test
public void shouldReadRecordedInternalUrlForUnsupportedType() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/note.txt";
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new RecordedFileStorageService(fileUrl, "recorded text"),
new ReadingReaderManager());
String content = extractor.extract(buildFileValue(
"note.txt",
fileUrl,
"text/plain"));
Assert.assertEquals("recorded text", content);
Assert.assertNull(bridgeService.lastSource);
}
/** /**
* 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。 * 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。
* *
@@ -574,4 +596,42 @@ public class DocNodeFileContentExtractorTest {
return 0L; return 0L;
} }
} }
/**
* 仅允许通过服务端记录句柄读取内容的存储测试替身。
*/
private static class RecordedFileStorageService
extends FailingFileStorageService {
/** 允许解析的精确 URL。 */
private final String recordedUrl;
/** 固定内容。 */
private final byte[] content;
/** 固定可信读取句柄。 */
private final FileStorageWriteHandle handle = new FileStorageWriteHandle(
"recorded", "", "/storage", "attachment", "note.txt");
/**
* 创建服务端记录存储替身。
*
* @param recordedUrl 允许解析的精确 URL
* @param content 固定文本内容
*/
private RecordedFileStorageService(String recordedUrl, String content) {
this.recordedUrl = recordedUrl;
this.content = content.getBytes(StandardCharsets.UTF_8);
}
/** {@inheritDoc} */
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
return recordedUrl.equals(reference) ? Optional.of(handle) : Optional.empty();
}
/** {@inheritDoc} */
@Override
public InputStream readRecoverable(FileStorageWriteHandle requestedHandle) {
Assert.assertSame(handle, requestedHandle);
return new ByteArrayInputStream(content);
}
}
} }