fix: 支持可信内部文件引用读取
- 通过服务端文件记录和存储配置恢复可信物理读取句柄 - 文档解析与工作流文档节点优先读取已确认的内部存储对象 - 补齐路径校验、记录异常和读取边界测试
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 严格按句柄中的后端精确删除物理对象。
|
* 严格按句柄中的后端精确删除物理对象。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 精确且幂等地删除句柄对应的物理对象。
|
* 精确且幂等地删除句柄对应的物理对象。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user