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

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

View File

@@ -15,6 +15,7 @@ import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
import tech.easyflow.ai.document.support.DocumentParseSourceType;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -326,20 +327,29 @@ public class DocNodeFileContentExtractor {
private void copySourceToTemporaryFile(
DocumentSourceRef sourceRef, Path target) throws IOException {
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)
&& (!isRemoteUrl(filePath)
|| (uploadedFileReader != null
&& uploadedFileReader.isManagedPathCandidate(filePath)));
|| managedUpload
|| trustedFile.isPresent());
if (localStorage) {
try (IoBulkhead.Permit ignored =
IoBulkhead.storage().acquire("storage:document-read");
InputStream inputStream = openInputStream(sourceRef);
InputStream inputStream = openInputStream(sourceRef, trustedFile);
OutputStream outputStream = Files.newOutputStream(target)) {
copy(inputStream, outputStream);
}
return;
}
try (InputStream inputStream = openInputStream(sourceRef);
try (InputStream inputStream = openInputStream(sourceRef, trustedFile);
OutputStream outputStream = Files.newOutputStream(target)) {
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();
if (uploadedFileReader != null && StringUtil.hasText(filePath)) {
Optional<InputStream> managed = uploadedFileReader.openVerified(filePath);
@@ -372,6 +392,11 @@ public class DocNodeFileContentExtractor {
FILE_MAX_SINGLE_SIZE);
}
}
if (trustedFile.isPresent()) {
return DocumentInputStreamSupport.limit(
fileStorageService.readRecoverable(trustedFile.get()),
FILE_MAX_SINGLE_SIZE);
}
if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) {
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.model.DocumentSourceRef;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import java.io.ByteArrayInputStream;
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 会走恢复句柄读取。
*
@@ -217,4 +236,42 @@ public class DocumentSourceLoaderTest {
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.service.DocumentParseBridgeService;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayInputStream;
@@ -203,6 +204,27 @@ public class DocNodeFileContentExtractorTest {
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 的非桥接文件通过记录校验后走内部存储读取。
*
@@ -574,4 +596,42 @@ public class DocNodeFileContentExtractorTest {
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);
}
}
}