diff --git a/jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/excel/DefaultImportExportService.java b/jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/excel/DefaultImportExportService.java index bd1ff9ba5..b0478b9f3 100644 --- a/jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/excel/DefaultImportExportService.java +++ b/jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/excel/DefaultImportExportService.java @@ -20,16 +20,11 @@ import org.jetlinks.community.io.excel.easyexcel.ExcelReadDataListener; import org.jetlinks.community.io.file.FileManager; import org.jetlinks.community.io.utils.FileUtils; -import org.springframework.core.io.Resource; -import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.io.FileInputStream; import java.io.InputStream; import static org.hswebframework.reactor.excel.ReactorExcel.read; @@ -41,13 +36,9 @@ @Component public class DefaultImportExportService implements ImportExportService { - private WebClient client; - private final FileManager fileManager; - public DefaultImportExportService(WebClient.Builder builder, - FileManager fileManager) { - client = builder.build(); + public DefaultImportExportService(FileManager fileManager) { this.fileManager = fileManager; } @@ -80,19 +71,6 @@ public Flux readData(String fileUrl, String fileId, RowWrapper wrapper } public Mono getInputStream(String fileUrl) { - - return Mono.defer(() -> { - if (fileUrl.startsWith("http")) { - return client - .get() - .uri(fileUrl) - .accept(MediaType.APPLICATION_OCTET_STREAM) - .exchangeToMono(clientResponse -> clientResponse.bodyToMono(Resource.class)) - .flatMap(resource -> Mono.fromCallable(resource::getInputStream)); - } else { - return Mono.fromCallable(() -> new FileInputStream(fileUrl)); - } - }); - + return FileUtils.readManagedInputStream(fileManager, fileUrl); } } diff --git a/jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/utils/FileUtils.java b/jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/utils/FileUtils.java index 0e7eb7272..5243010a0 100644 --- a/jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/utils/FileUtils.java +++ b/jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/utils/FileUtils.java @@ -17,7 +17,9 @@ import io.netty.buffer.ByteBufAllocator; import org.apache.commons.io.FilenameUtils; +import org.hswebframework.web.exception.ValidationException; import org.jetlinks.core.message.codec.http.HttpUtils; +import org.jetlinks.community.io.file.FileManager; import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; @@ -31,10 +33,18 @@ import java.io.FileInputStream; import java.io.InputStream; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Paths; import java.security.MessageDigest; +/** + * 文件读取与媒体类型工具。 + * + * 通用 URL 读取保留既有远程和本地文件能力;来自业务请求的托管文件必须通过 + * {@link #readManagedInputStream(FileManager, String)} 读取,避免绕过 {@link FileManager} + * 直接访问网络或本地文件。 + */ public class FileUtils { public static String getExtension(String url) { @@ -136,6 +146,77 @@ public static Mono dataBufferToInputStream(Flux dataBuf } + /** + * 读取平台托管文件。 + * + * 仅接受 {@link FileManager} 文件 ID 或包含 {@code /file/{id}} 的平台文件访问地址。 + * 访问地址只用于解析文件 ID,不会发起 HTTP 请求或读取本地路径。 + * + * @param fileManager 文件管理器 + * @param fileUrlOrId 平台文件访问地址或文件 ID + * @return 文件输入流,调用方使用完毕后必须关闭 + */ + public static Mono readManagedInputStream(FileManager fileManager, + String fileUrlOrId) { + return Mono.defer(() -> dataBufferToInputStream( + fileManager.read(resolveManagedFileId(fileUrlOrId)) + )); + } + + /** + * 从平台文件访问地址或文件 ID 中解析托管文件 ID。 + * + * @param fileUrlOrId 平台文件访问地址或文件 ID + * @return 托管文件 ID + * @throws ValidationException 输入不是托管文件 ID 或平台文件访问地址 + */ + public static String resolveManagedFileId(String fileUrlOrId) { + if (!StringUtils.hasText(fileUrlOrId)) { + throw unsupportedManagedFile(); + } + + URI uri; + try { + uri = URI.create(fileUrlOrId); + } catch (IllegalArgumentException e) { + throw unsupportedManagedFile(); + } + + String path = uri.getPath(); + if (!uri.isAbsolute() + && !fileUrlOrId.contains("/") + && !fileUrlOrId.contains("\\")) { + if (uri.getQuery() == null + && uri.getFragment() == null + && StringUtils.hasText(path)) { + return path; + } + throw unsupportedManagedFile(); + } + + int filePathIndex = path == null ? -1 : path.lastIndexOf("/file/"); + if (filePathIndex < 0) { + throw unsupportedManagedFile(); + } + + String fileName = path.substring(filePathIndex + "/file/".length()); + if (!StringUtils.hasText(fileName) + || fileName.contains("/") + || fileName.contains("\\")) { + throw unsupportedManagedFile(); + } + + int extensionIndex = fileName.indexOf('.'); + if (extensionIndex == 0) { + throw unsupportedManagedFile(); + } + return extensionIndex > 0 ? fileName.substring(0, extensionIndex) : fileName; + } + + private static ValidationException unsupportedManagedFile() { + return new ValidationException.NoStackTrace("error.only_managed_file_supported"); + } + public static Flux readDataBuffer(WebClient client, String fileUrl) { if (fileUrl.startsWith("http")) { diff --git a/jetlinks-components/io-component/src/main/resources/i18n/io-component/messages_en.properties b/jetlinks-components/io-component/src/main/resources/i18n/io-component/messages_en.properties index c2903417e..9fc527d14 100644 --- a/jetlinks-components/io-component/src/main/resources/i18n/io-component/messages_en.properties +++ b/jetlinks-components/io-component/src/main/resources/i18n/io-component/messages_en.properties @@ -1,4 +1,5 @@ excel.write.true.text=yes excel.write.false.text=no excel.read.true.text=yes -excel.read.false.text=no \ No newline at end of file +excel.read.false.text=no +error.only_managed_file_supported=Only managed file IDs or access URLs are supported diff --git a/jetlinks-components/io-component/src/main/resources/i18n/io-component/messages_zh.properties b/jetlinks-components/io-component/src/main/resources/i18n/io-component/messages_zh.properties index 372a73083..821e65f9a 100644 --- a/jetlinks-components/io-component/src/main/resources/i18n/io-component/messages_zh.properties +++ b/jetlinks-components/io-component/src/main/resources/i18n/io-component/messages_zh.properties @@ -1,4 +1,5 @@ excel.write.true.text=\u662f excel.write.false.text=\u5426 excel.read.true.text=\u662f -excel.read.false.text=\u5426 \ No newline at end of file +excel.read.false.text=\u5426 +error.only_managed_file_supported=\u4ec5\u652f\u6301\u5e73\u53f0\u6258\u7ba1\u6587\u4ef6ID\u6216\u8bbf\u95ee\u5730\u5740 diff --git a/jetlinks-components/io-component/src/test/java/org/jetlinks/community/io/excel/DefaultImportExportServiceTest.java b/jetlinks-components/io-component/src/test/java/org/jetlinks/community/io/excel/DefaultImportExportServiceTest.java new file mode 100644 index 000000000..f2a04273b --- /dev/null +++ b/jetlinks-components/io-component/src/test/java/org/jetlinks/community/io/excel/DefaultImportExportServiceTest.java @@ -0,0 +1,133 @@ +/* + * Copyright 2026 JetLinks https://www.jetlinks.cn + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jetlinks.community.io.excel; + +import org.hswebframework.web.exception.ValidationException; +import org.jetlinks.community.io.file.FileManager; +import org.jetlinks.community.io.utils.FileUtils; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class DefaultImportExportServiceTest { + + @Test + void shouldReadManagedFileInsteadOfRemoteUrl() { + byte[] content = "managed-file".getBytes(StandardCharsets.UTF_8); + FileManager fileManager = mock(FileManager.class); + when(fileManager.read("file-id")) + .thenReturn(Flux.just(DefaultDataBufferFactory.sharedInstance.wrap(content))); + + DefaultImportExportService service = new DefaultImportExportService(fileManager); + + StepVerifier + .create(service + .getInputStream("http://localhost:8848/api/file/file-id.csv?accessKey=test") + .map(DefaultImportExportServiceTest::readAllBytes)) + .assertNext(actual -> assertArrayEquals(content, actual)) + .verifyComplete(); + + verify(fileManager).read("file-id"); + } + + @Test + void shouldRejectExternalOrLocalFileBeforeReading() { + FileManager fileManager = mock(FileManager.class); + DefaultImportExportService service = new DefaultImportExportService(fileManager); + + StepVerifier + .create(service.getInputStream("http://127.0.0.1/internal/secret.csv")) + .expectError(ValidationException.class) + .verify(); + StepVerifier + .create(service.getInputStream("/etc/passwd")) + .expectError(ValidationException.class) + .verify(); + + verifyNoInteractions(fileManager); + } + + @Test + void shouldResolveManagedFileId() { + assertEquals("file-id", FileUtils.resolveManagedFileId("file-id")); + assertEquals( + "file-id", + FileUtils.resolveManagedFileId("http://localhost:8848/api/file/file-id.xlsx?accessKey=test") + ); + assertEquals( + "file-id", + FileUtils.resolveManagedFileId("/api/file/file-id.xlsx?accessKey=test") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId("http://127.0.0.1/internal/secret.csv") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId("/etc/passwd") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId("file:///etc/passwd") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId(" ") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId("/api/file/.xlsx") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId("/api/file/") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId("/api/file/file-id/extra.xlsx") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId("file-id?accessKey=test") + ); + assertThrows( + ValidationException.class, + () -> FileUtils.resolveManagedFileId("http://[::1") + ); + } + + private static byte[] readAllBytes(InputStream stream) { + try (stream) { + return stream.readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceInstanceController.java b/jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceInstanceController.java index a4e62d294..f3c4125f9 100644 --- a/jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceInstanceController.java +++ b/jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceInstanceController.java @@ -1078,7 +1078,7 @@ public Mono deviceIdValidate2(@RequestParam @Parameter(descrip @SaveAction @Operation(summary = "解析文件为属性物模型") public Mono importPropertyMetadata(@PathVariable @Parameter(description = "产品ID") String productId, - @RequestParam @Parameter(description = "文件地址,支持csv,xlsx文件格式") String fileUrl) { + @RequestParam @Parameter(description = "平台文件ID或访问地址,支持csv,xlsx文件格式") String fileUrl) { return metadataManager .getMetadataExpandsConfig(productId, DeviceMetadataType.property, "*", "*", DeviceConfigScope.device) .collectList() diff --git a/jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceProductController.java b/jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceProductController.java index 2bb45fc8a..8081072df 100644 --- a/jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceProductController.java +++ b/jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceProductController.java @@ -336,7 +336,7 @@ public Mono downloadExportPropertyMetadataTemplate(@PathVariable @Paramete @SaveAction @Operation(summary = "解析文件为属性物模型") public Mono importPropertyMetadata(@PathVariable @Parameter(description = "产品ID") String productId, - @RequestParam @Parameter(description = "文件地址,支持csv,xlsx文件格式") String fileUrl) { + @RequestParam @Parameter(description = "平台文件ID或访问地址,支持csv,xlsx文件格式") String fileUrl) { return configMetadataManager .getMetadataExpandsConfig(productId, DeviceMetadataType.property, "*", "*", DeviceConfigScope.product) .collectList() @@ -355,4 +355,4 @@ public Mono importPropertyMetadata(@PathVariable @Parameter(description }); } -} \ No newline at end of file +}