diff --git a/api/build.gradle b/api/build.gradle
index f16fe21f28..717b853d1a 100644
--- a/api/build.gradle
+++ b/api/build.gradle
@@ -65,6 +65,7 @@ dependencies {
api "org.springframework.integration:spring-integration-core"
api "com.github.java-json-tools:json-patch"
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
+ api 'org.apache.tika:tika-core'
api "io.github.resilience4j:resilience4j-spring-boot3"
api "io.github.resilience4j:resilience4j-reactor"
diff --git a/api/src/main/java/run/halo/app/infra/FileCategoryMatcher.java b/api/src/main/java/run/halo/app/infra/FileCategoryMatcher.java
new file mode 100644
index 0000000000..705c8f60b9
--- /dev/null
+++ b/api/src/main/java/run/halo/app/infra/FileCategoryMatcher.java
@@ -0,0 +1,107 @@
+package run.halo.app.infra;
+
+import java.util.Set;
+
+/**
+ *
Classifies files based on their MIME types.
+ * It provides different categories such as IMAGE, SVG, AUDIO, VIDEO, ARCHIVE, and DOCUMENT.
+ * Each category has a match
method that checks if a given MIME type belongs to that
+ * category.
+ * The categories are defined as follows:
+ *
+ * - IMAGE: Matches all image MIME types except for SVG.
+ * - SVG: Specifically matches the SVG image MIME type.
+ * - AUDIO: Matches all audio MIME types.
+ * - VIDEO: Matches all video MIME types.
+ * - ARCHIVE: Matches common archive MIME types like zip, rar, tar, etc.
+ * - DOCUMENT: Matches common document MIME types like plain text, PDF, Word, Excel, etc.
+ *
+ *
+ * @author guqing
+ * @since 2.18.0
+ */
+public enum FileCategoryMatcher {
+ ALL {
+ @Override
+ public boolean match(String mimeType) {
+ return true;
+ }
+ },
+ IMAGE {
+ @Override
+ public boolean match(String mimeType) {
+ return mimeType.startsWith("image/") && !mimeType.equals("image/svg+xml");
+ }
+ },
+ SVG {
+ @Override
+ public boolean match(String mimeType) {
+ return mimeType.equals("image/svg+xml");
+ }
+ },
+ AUDIO {
+ @Override
+ public boolean match(String mimeType) {
+ return mimeType.startsWith("audio/");
+ }
+ },
+ VIDEO {
+ @Override
+ public boolean match(String mimeType) {
+ return mimeType.startsWith("video/");
+ }
+ },
+ ARCHIVE {
+ static final Set ARCHIVE_MIME_TYPES = Set.of(
+ "application/zip",
+ "application/x-rar-compressed",
+ "application/x-tar",
+ "application/gzip",
+ "application/x-bzip2",
+ "application/x-xz",
+ "application/x-7z-compressed"
+ );
+
+ @Override
+ public boolean match(String mimeType) {
+ return ARCHIVE_MIME_TYPES.contains(mimeType);
+ }
+ },
+ DOCUMENT {
+ static final Set DOCUMENT_MIME_TYPES = Set.of(
+ "text/plain",
+ "application/rtf",
+ "text/csv",
+ "text/xml",
+ "application/pdf",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-powerpoint",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.oasis.opendocument.text",
+ "application/vnd.oasis.opendocument.spreadsheet",
+ "application/vnd.oasis.opendocument.presentation"
+ );
+
+ @Override
+ public boolean match(String mimeType) {
+ return DOCUMENT_MIME_TYPES.contains(mimeType);
+ }
+ };
+
+ public abstract boolean match(String mimeType);
+
+ /**
+ * Get the file category matcher by name.
+ */
+ public static FileCategoryMatcher of(String name) {
+ for (var matcher : values()) {
+ if (matcher.name().equalsIgnoreCase(name)) {
+ return matcher;
+ }
+ }
+ throw new IllegalArgumentException("Unsupported file category matcher for name: " + name);
+ }
+}
diff --git a/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java b/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java
new file mode 100644
index 0000000000..f9326863ee
--- /dev/null
+++ b/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java
@@ -0,0 +1,34 @@
+package run.halo.app.infra.utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import lombok.experimental.UtilityClass;
+import org.apache.tika.Tika;
+import org.apache.tika.mime.MimeTypeException;
+import org.apache.tika.mime.MimeTypes;
+
+@UtilityClass
+public class FileTypeDetectUtils {
+
+ private static final Tika tika = new Tika();
+
+ /**
+ * Detect mime type.
+ *
+ * @param inputStream input stream will be closed after detection.
+ */
+ public static String detectMimeType(InputStream inputStream) throws IOException {
+ try {
+ return tika.detect(inputStream);
+ } finally {
+ if (inputStream != null) {
+ inputStream.close();
+ }
+ }
+ }
+
+ public static String detectFileExtension(String mimeType) throws MimeTypeException {
+ MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes();
+ return mimeTypes.forName(mimeType).getExtension();
+ }
+}
diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java
index fc97c217ff..60ef6c9c50 100644
--- a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java
+++ b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java
@@ -15,15 +15,20 @@
import java.util.ArrayList;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
+import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.MediaType;
+import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
+import org.springframework.util.unit.DataSize;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import reactor.core.Exceptions;
@@ -38,8 +43,12 @@
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.ExternalUrlSupplier;
+import run.halo.app.infra.FileCategoryMatcher;
import run.halo.app.infra.exception.AttachmentAlreadyExistsException;
+import run.halo.app.infra.exception.FileSizeExceededException;
+import run.halo.app.infra.exception.FileTypeNotAllowedException;
import run.halo.app.infra.properties.HaloProperties;
+import run.halo.app.infra.utils.FileTypeDetectUtils;
import run.halo.app.infra.utils.JsonUtils;
@Slf4j
@@ -81,7 +90,7 @@ public Mono upload(UploadContext uploadOption) {
}
checkDirectoryTraversal(uploadRoot, attachmentPath);
- return Mono.fromRunnable(
+ return validateFile(file, setting).then(Mono.fromRunnable(
() -> {
try {
// init parent folders
@@ -125,10 +134,55 @@ public Mono upload(UploadContext uploadOption) {
return attachment;
})
.onErrorMap(FileAlreadyExistsException.class,
- e -> new AttachmentAlreadyExistsException(e.getFile()));
+ e -> new AttachmentAlreadyExistsException(e.getFile()))
+ );
});
}
+ private Mono validateFile(FilePart file, PolicySetting setting) {
+ var validations = new ArrayList>(2);
+ var maxSize = setting.getMaxFileSize();
+ if (maxSize != null && maxSize.toBytes() > 0) {
+ validations.add(
+ file.content()
+ .map(DataBuffer::readableByteCount)
+ .reduce(0L, Long::sum)
+ .filter(size -> size <= setting.getMaxFileSize().toBytes())
+ .switchIfEmpty(Mono.error(new FileSizeExceededException(
+ "File size exceeds the maximum limit",
+ "problemDetail.attachment.upload.fileSizeExceeded",
+ new Object[] {setting.getMaxFileSize().toKilobytes() + "KB"})
+ ))
+ );
+ }
+ if (!CollectionUtils.isEmpty(setting.getAllowedFileTypes())) {
+ var typeValidator = file.content()
+ .next()
+ .handle((dataBuffer, sink) -> {
+ var mimeType = "Unknown";
+ try {
+ mimeType = FileTypeDetectUtils.detectMimeType(dataBuffer.asInputStream());
+ var isAllow = setting.getAllowedFileTypes()
+ .stream()
+ .map(FileCategoryMatcher::of)
+ .anyMatch(matcher -> matcher.match(file.filename()));
+ if (isAllow) {
+ sink.next(dataBuffer);
+ return;
+ }
+ } catch (IOException e) {
+ log.warn("Failed to detect file type", e);
+ }
+ sink.error(new FileTypeNotAllowedException("File type is not allowed",
+ "problemDetail.attachment.upload.fileTypeNotSupported",
+ new Object[] {mimeType})
+ );
+ });
+ validations.add(typeValidator);
+ }
+ return Mono.when(validations);
+ }
+
@Override
public Mono delete(DeleteContext deleteContext) {
return Mono.just(deleteContext)
@@ -206,6 +260,16 @@ public static class PolicySetting {
private String location;
+ private DataSize maxFileSize;
+
+ private Set allowedFileTypes;
+
+ public void setMaxFileSize(String maxFileSize) {
+ if (!StringUtils.hasText(maxFileSize)) {
+ return;
+ }
+ this.maxFileSize = DataSize.parse(maxFileSize);
+ }
}
/**
diff --git a/application/src/main/java/run/halo/app/infra/exception/FileSizeExceededException.java b/application/src/main/java/run/halo/app/infra/exception/FileSizeExceededException.java
new file mode 100644
index 0000000000..bc26cb031b
--- /dev/null
+++ b/application/src/main/java/run/halo/app/infra/exception/FileSizeExceededException.java
@@ -0,0 +1,18 @@
+package run.halo.app.infra.exception;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+
+public class FileSizeExceededException extends ResponseStatusException {
+
+ public FileSizeExceededException(String reason, String messageDetailCode,
+ Object[] messageDetailArguments) {
+ this(reason, null, messageDetailCode, messageDetailArguments);
+ }
+
+ public FileSizeExceededException(String reason, Throwable cause,
+ String messageDetailCode, Object[] messageDetailArguments) {
+ super(HttpStatus.PAYLOAD_TOO_LARGE, reason, cause, messageDetailCode,
+ messageDetailArguments);
+ }
+}
diff --git a/application/src/main/java/run/halo/app/infra/exception/FileTypeNotAllowedException.java b/application/src/main/java/run/halo/app/infra/exception/FileTypeNotAllowedException.java
new file mode 100644
index 0000000000..6e7abe5cc4
--- /dev/null
+++ b/application/src/main/java/run/halo/app/infra/exception/FileTypeNotAllowedException.java
@@ -0,0 +1,18 @@
+package run.halo.app.infra.exception;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+
+public class FileTypeNotAllowedException extends ResponseStatusException {
+
+ public FileTypeNotAllowedException(String reason, String messageDetailCode,
+ Object[] messageDetailArguments) {
+ this(reason, null, messageDetailCode, messageDetailArguments);
+ }
+
+ public FileTypeNotAllowedException(String reason, Throwable cause,
+ String messageDetailCode, Object[] messageDetailArguments) {
+ super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, reason, cause, messageDetailCode,
+ messageDetailArguments);
+ }
+}
diff --git a/application/src/main/resources/config/i18n/messages.properties b/application/src/main/resources/config/i18n/messages.properties
index b885738013..8d1bf19bd2 100644
--- a/application/src/main/resources/config/i18n/messages.properties
+++ b/application/src/main/resources/config/i18n/messages.properties
@@ -11,6 +11,8 @@ problemDetail.title.org.springframework.web.server.MethodNotAllowedException=Met
problemDetail.title.org.springframework.security.authentication.BadCredentialsException=Bad Credentials
problemDetail.title.run.halo.app.extension.exception.SchemaViolationException=Schema Violation
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=Attachment Already Exists
+problemDetail.title.run.halo.app.infra.exception.FileTypeNotAllowedException=File Type Not Allowed
+problemDetail.title.run.halo.app.infra.exception.FileSizeExceededException=File Size Exceeded
problemDetail.title.run.halo.app.infra.exception.AccessDeniedException=Access Denied
problemDetail.title.reactor.core.Exceptions.RetryExhaustedException=Retry Exhausted
problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=Theme Install Error
@@ -75,5 +77,7 @@ problemDetail.plugin.missingManifest=Missing plugin manifest file "plugin.yaml"
problemDetail.internalServerError=Something went wrong, please try again later.
problemDetail.conflict=Conflict detected, please check the data and retry.
problemDetail.migration.backup.notFound=The backup file does not exist or has been deleted.
+problemDetail.attachment.upload.fileSizeExceeded=Make sure the file size is less than {0}.
+problemDetail.attachment.upload.fileTypeNotSupported=Unsupported upload of {0} type files.
title.visibility.identification.private=(Private)
\ No newline at end of file
diff --git a/application/src/main/resources/config/i18n/messages_zh.properties b/application/src/main/resources/config/i18n/messages_zh.properties
index 064be75f49..818f7e35e9 100644
--- a/application/src/main/resources/config/i18n/messages_zh.properties
+++ b/application/src/main/resources/config/i18n/messages_zh.properties
@@ -3,6 +3,8 @@ problemDetail.title.org.springframework.security.authentication.BadCredentialsEx
problemDetail.title.run.halo.app.infra.exception.UnsatisfiedAttributeValueException=请求参数属性值不满足要求
problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=插件安装失败
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在
+problemDetail.title.run.halo.app.infra.exception.FileTypeNotAllowedException=文件类型不允许
+problemDetail.title.run.halo.app.infra.exception.FileSizeExceededException=文件大小超出限制
problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=名称重复
problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=插件已存在
problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=主题安装失败
@@ -47,5 +49,7 @@ problemDetail.theme.install.alreadyExists=主题 {0} 已存在。
problemDetail.internalServerError=服务器内部发生错误,请稍候再试。
problemDetail.conflict=检测到冲突,请检查数据后重试。
problemDetail.migration.backup.notFound=备份文件不存在或已删除。
+problemDetail.attachment.upload.fileSizeExceeded=最大支持上传 {0} 大小的文件。
+problemDetail.attachment.upload.fileTypeNotSupported=不支持上传 {0} 类型的文件。
title.visibility.identification.private=(私有)
\ No newline at end of file
diff --git a/application/src/main/resources/extensions/attachment-local-policy.yaml b/application/src/main/resources/extensions/attachment-local-policy.yaml
index 136734f07e..c4a331c723 100644
--- a/application/src/main/resources/extensions/attachment-local-policy.yaml
+++ b/application/src/main/resources/extensions/attachment-local-policy.yaml
@@ -35,6 +35,33 @@ spec:
name: location
label: 存储位置
help: ~/.halo2/attachments/upload 下的子目录
+ - $formkit: text
+ name: maxFileSize
+ label: 最大单文件大小
+ validation: [['matches', '/^(0|[1-9]\d*)(?:[KMG]B)?$/']]
+ validation-visibility: "live"
+ validation-messages:
+ matches: "输入格式错误,遵循:整数 + 大写的单位(KB, MB, GB)"
+ help: "0 表示不限制,示例:5KB、10MB、1GB"
+ - $formkit: checkbox
+ name: allowedFileTypes
+ label: 文件类型限制
+ help: 限制允许上传的文件类型
+ options:
+ - label: 无限制
+ value: ALL
+ - label: 图片
+ value: IMAGE
+ - label: SVG
+ value: SVG
+ - label: 视频
+ value: VIDEO
+ - label: 音频
+ value: AUDIO
+ - label: 文档
+ value: DOCUMENT
+ - label: 压缩包
+ value: ARCHIVE
---
apiVersion: storage.halo.run/v1alpha1
kind: Group
diff --git a/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java
new file mode 100644
index 0000000000..a69ee0c806
--- /dev/null
+++ b/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java
@@ -0,0 +1,45 @@
+package run.halo.app.infra.utils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import org.apache.tika.mime.MimeTypeException;
+import org.junit.jupiter.api.Test;
+import org.springframework.util.ResourceUtils;
+
+/**
+ * Test for {@link FileTypeDetectUtils}.
+ *
+ * @author guqing
+ * @since 2.18.0
+ */
+class FileTypeDetectUtilsTest {
+
+ @Test
+ void detectMimeTypeTest() throws IOException {
+ var file = ResourceUtils.getFile("classpath:app.key");
+ String mimeType = FileTypeDetectUtils.detectMimeType(Files.newInputStream(file.toPath()));
+ assertThat(mimeType).isEqualTo("application/x-x509-key; format=pem");
+
+ file = ResourceUtils.getFile("classpath:console/index.html");
+ mimeType = FileTypeDetectUtils.detectMimeType(Files.newInputStream(file.toPath()));
+ assertThat(mimeType).isEqualTo("text/plain");
+
+ file = ResourceUtils.getFile("classpath:themes/test-theme.zip");
+ mimeType = FileTypeDetectUtils.detectMimeType(Files.newInputStream(file.toPath()));
+ assertThat(mimeType).isEqualTo("application/zip");
+ }
+
+ @Test
+ void detectFileExtensionTest() throws MimeTypeException {
+ var ext = FileTypeDetectUtils.detectFileExtension("application/x-x509-key; format=pem");
+ assertThat(ext).isEqualTo("");
+
+ ext = FileTypeDetectUtils.detectFileExtension("text/plain");
+ assertThat(ext).isEqualTo(".txt");
+
+ ext = FileTypeDetectUtils.detectFileExtension("application/zip");
+ assertThat(ext).isEqualTo(".zip");
+ }
+}
diff --git a/platform/application/build.gradle b/platform/application/build.gradle
index 2614ca1bdc..18f3715181 100644
--- a/platform/application/build.gradle
+++ b/platform/application/build.gradle
@@ -22,6 +22,7 @@ ext {
lucene = "9.11.1"
resilience4jVersion = "2.2.0"
twoFactorAuth = "1.3"
+ tika = "2.9.2"
}
javaPlatform {
@@ -54,6 +55,7 @@ dependencies {
api "io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion"
api "io.github.resilience4j:resilience4j-reactor:$resilience4jVersion"
api "com.j256.two-factor-auth:two-factor-auth:$twoFactorAuth"
+ api "org.apache.tika:tika-core:$tika"
}
}