feat(education): admit secure import assets

This commit is contained in:
2026-07-31 12:50:42 +08:00
parent b6a30e0b7c
commit 7f12dd35c8
16 changed files with 504 additions and 8 deletions

View File

@@ -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;
}
/**
* 生成文件预签名地址,用于读取
*

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}

View File

@@ -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"));
}
}