Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -80,19 +71,6 @@ public <T> Flux<T> readData(String fileUrl, String fileId, RowWrapper<T> wrapper
}

public Mono<InputStream> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -136,6 +146,77 @@ public static Mono<InputStream> dataBufferToInputStream(Flux<DataBuffer> dataBuf

}

/**
* 读取平台托管文件。
*
* 仅接受 {@link FileManager} 文件 ID 或包含 {@code /file/{id}} 的平台文件访问地址。
* 访问地址只用于解析文件 ID,不会发起 HTTP 请求或读取本地路径。
*
* @param fileManager 文件管理器
* @param fileUrlOrId 平台文件访问地址或文件 ID
* @return 文件输入流,调用方使用完毕后必须关闭
*/
public static Mono<InputStream> 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<DataBuffer> readDataBuffer(WebClient client,
String fileUrl) {
if (fileUrl.startsWith("http")) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
excel.write.true.text=yes
excel.write.false.text=no
excel.read.true.text=yes
excel.read.false.text=no
excel.read.false.text=no
error.only_managed_file_supported=Only managed file IDs or access URLs are supported
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
excel.write.true.text=\u662f
excel.write.false.text=\u5426
excel.read.true.text=\u662f
excel.read.false.text=\u5426
excel.read.false.text=\u5426
error.only_managed_file_supported=\u4ec5\u652f\u6301\u5e73\u53f0\u6258\u7ba1\u6587\u4ef6ID\u6216\u8bbf\u95ee\u5730\u5740
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@ public Mono<ValidationResult> deviceIdValidate2(@RequestParam @Parameter(descrip
@SaveAction
@Operation(summary = "解析文件为属性物模型")
public Mono<String> 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ public Mono<Void> downloadExportPropertyMetadataTemplate(@PathVariable @Paramete
@SaveAction
@Operation(summary = "解析文件为属性物模型")
public Mono<String> 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()
Expand All @@ -355,4 +355,4 @@ public Mono<String> importPropertyMetadata(@PathVariable @Parameter(description
});
}

}
}
Loading