feat(education): admit secure import assets
This commit is contained in:
@@ -42,6 +42,21 @@ public interface FileApi {
|
||||
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
||||
String name, String directory, String type);
|
||||
|
||||
/**
|
||||
* 保存有界文件内容,返回不泄露存储实现的公开描述。
|
||||
*/
|
||||
default FileDescriptor createFile(FileContent content) {
|
||||
String reference = createFile(content.bytes(), content.name(), content.directory(), content.contentType());
|
||||
return new FileDescriptor(reference, content.name(), content.contentType(), content.bytes().length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件恶意内容扫描状态。扫描能力未配置或不可用时必须返回 UNAVAILABLE。
|
||||
*/
|
||||
default FileScanStatus scan(FileDescriptor file) {
|
||||
return FileScanStatus.UNAVAILABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址,用于读取
|
||||
*
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package cn.iocoder.yudao.module.infra.api.file;
|
||||
|
||||
import cn.iocoder.yudao.module.infra.service.file.FileService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 文件 API 实现类
|
||||
*
|
||||
@@ -14,8 +15,11 @@ import org.springframework.validation.annotation.Validated;
|
||||
@Validated
|
||||
public class FileApiImpl implements FileApi {
|
||||
|
||||
@Resource
|
||||
private FileService fileService;
|
||||
private final FileService fileService;
|
||||
|
||||
public FileApiImpl(FileService fileService) {
|
||||
this.fileService = Objects.requireNonNull(fileService);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createFile(byte[] content, String name, String directory, String type) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.infra.api.file;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** Bounded file content accepted by the public Infra file boundary. */
|
||||
public record FileContent(byte[] bytes, String name, String directory, String contentType) {
|
||||
|
||||
public static final int MAX_CONTENT_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
public FileContent {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
throw new IllegalArgumentException("file content must not be empty");
|
||||
}
|
||||
if (bytes.length > MAX_CONTENT_BYTES) {
|
||||
throw new IllegalArgumentException("file content exceeds public contract limit");
|
||||
}
|
||||
bytes = Arrays.copyOf(bytes, bytes.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] bytes() {
|
||||
return Arrays.copyOf(bytes, bytes.length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.iocoder.yudao.module.infra.api.file;
|
||||
|
||||
/** Public, storage-agnostic description of an accepted file. */
|
||||
public record FileDescriptor(String reference, String name, String contentType, long size) {
|
||||
|
||||
public FileDescriptor {
|
||||
if (reference == null || reference.isBlank()) {
|
||||
throw new IllegalArgumentException("file reference must not be blank");
|
||||
}
|
||||
if (size < 0 || size > FileContent.MAX_CONTENT_BYTES) {
|
||||
throw new IllegalArgumentException("file size is outside public contract bounds");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package cn.iocoder.yudao.module.infra.api.file;
|
||||
|
||||
/** Malware scanning outcome exposed without scanner implementation details. */
|
||||
public enum FileScanStatus {
|
||||
CLEAN,
|
||||
INFECTED,
|
||||
UNAVAILABLE
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.iocoder.yudao.module.infra.api.file;
|
||||
|
||||
import cn.iocoder.yudao.module.infra.service.file.FileService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.when;
|
||||
|
||||
class FileApiImplTest {
|
||||
|
||||
@Test
|
||||
void storesBoundedContentAsPublicDescriptor() {
|
||||
FileService fileService = mock(FileService.class);
|
||||
when(fileService.createFile(new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv"))
|
||||
.thenReturn("https://storage.example/internal/object");
|
||||
FileApiImpl api = new FileApiImpl(fileService);
|
||||
|
||||
FileDescriptor descriptor = api.createFile(new FileContent(
|
||||
new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv"));
|
||||
|
||||
assertEquals("https://storage.example/internal/object", descriptor.reference());
|
||||
assertEquals("questions.csv", descriptor.name());
|
||||
assertEquals("text/csv", descriptor.contentType());
|
||||
assertEquals(3, descriptor.size());
|
||||
verify(fileService).createFile(new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv");
|
||||
}
|
||||
|
||||
@Test
|
||||
void scannerUnavailableNeverReportsClean() {
|
||||
FileApiImpl api = new FileApiImpl(mock(FileService.class));
|
||||
|
||||
assertEquals(FileScanStatus.UNAVAILABLE,
|
||||
api.scan(new FileDescriptor("opaque", "questions.csv", "text/csv", 3)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsContentOutsidePublicContractBound() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new FileContent(new byte[FileContent.MAX_CONTENT_BYTES + 1], "large.csv", null, "text/csv"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user