From 47b6a37d0a0bcba680b7f461af4fd11889b1a1cd Mon Sep 17 00:00:00 2001 From: longjuan <769022681@qq.com> Date: Mon, 22 Apr 2024 12:26:10 +0800 Subject: [PATCH] feat: add verification function to the configuration of s3 object storage policy (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ```release-note 在 S3 存储策略配置中增加了验证配置的功能。 ``` fixes https://github.com/halo-dev/plugin-s3/issues/132 --- build.gradle | 4 +- .../PolicyConfigValidationController.java | 123 +++++++++ .../run/halo/s3os/S3OsAttachmentHandler.java | 12 +- .../extensions/policy-template-s3os.yaml | 248 +++++++++--------- .../extensions/s3os-role-template.yaml | 13 + src/main/resources/plugin.yaml | 2 +- src/main/resources/validation.jpg | Bin 0 -> 17896 bytes 7 files changed, 271 insertions(+), 131 deletions(-) create mode 100644 src/main/java/run/halo/s3os/PolicyConfigValidationController.java create mode 100644 src/main/resources/validation.jpg diff --git a/build.gradle b/build.gradle index d6f17c8..3b62a1b 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ repositories { } dependencies { - implementation platform('run.halo.tools.platform:plugin:2.13.0-SNAPSHOT') + implementation platform('run.halo.tools.platform:plugin:2.14.0-SNAPSHOT') compileOnly 'run.halo.app:api' implementation platform('software.amazon.awssdk:bom:2.19.8') @@ -36,7 +36,7 @@ configurations.runtimeClasspath { halo { - version = '2.12.1' + version = '2.14.0' } haloPlugin { diff --git a/src/main/java/run/halo/s3os/PolicyConfigValidationController.java b/src/main/java/run/halo/s3os/PolicyConfigValidationController.java new file mode 100644 index 0000000..f9afde4 --- /dev/null +++ b/src/main/java/run/halo/s3os/PolicyConfigValidationController.java @@ -0,0 +1,123 @@ +package run.halo.s3os; + +import static run.halo.s3os.S3OsAttachmentHandler.MULTIPART_MIN_PART_SIZE; +import static run.halo.s3os.S3OsAttachmentHandler.checkResult; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.infra.utils.PathUtils; +import run.halo.app.plugin.ApiVersion; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.utils.SdkAutoCloseable; + +@ApiVersion("s3os.halo.run/v1alpha1") +@RestController +@RequiredArgsConstructor +@Slf4j +public class PolicyConfigValidationController { + private final S3OsAttachmentHandler handler; + + @PostMapping("/policies/s3/validation") + public Mono validatePolicyConfig(@RequestBody S3OsProperties properties) { + var filename = "halo-s3-plugin-test-file-" + System.currentTimeMillis() + ".jpg"; + var content = readImage(); + return Mono.using(() -> handler.buildS3Client(properties), + client -> { + var uploadState = + new S3OsAttachmentHandler.UploadState(properties, filename, false); + + return handler.checkFileExistsAndRename(uploadState, client) + // init multipart upload + .flatMap(state -> Mono.fromCallable(() -> client.createMultipartUpload( + CreateMultipartUploadRequest.builder() + .bucket(properties.getBucket()) + .contentType(state.contentType) + .key(state.objectKey) + .build()))) + .doOnNext((response) -> { + checkResult(response, "createMultipartUpload"); + uploadState.uploadId = response.uploadId(); + }) + .thenMany(handler.reshape(content, MULTIPART_MIN_PART_SIZE)) + // buffer to part + .windowUntil((buffer) -> { + uploadState.buffered += buffer.readableByteCount(); + if (uploadState.buffered >= MULTIPART_MIN_PART_SIZE) { + uploadState.buffered = 0; + return true; + } else { + return false; + } + }) + // upload part + .concatMap((window) -> window.collectList().flatMap((bufferList) -> { + var buffer = S3OsAttachmentHandler.concatBuffers(bufferList); + return handler.uploadPart(uploadState, buffer, client); + })) + .reduce(uploadState, (state, completedPart) -> { + state.completedParts.put(completedPart.partNumber(), completedPart); + return state; + }) + // complete multipart upload + .flatMap((state) -> Mono.just(client.completeMultipartUpload( + CompleteMultipartUploadRequest + .builder() + .bucket(properties.getBucket()) + .uploadId(state.uploadId) + .multipartUpload(CompletedMultipartUpload.builder() + .parts(state.completedParts.values()) + .build()) + .key(state.objectKey) + .build()) + )) + // get object metadata + .flatMap((response) -> { + checkResult(response, "completeUpload"); + return Mono.just(client.headObject( + HeadObjectRequest.builder() + .bucket(properties.getBucket()) + .key(uploadState.objectKey) + .build() + )); + }) + // check object metadata + .doOnNext((response) -> { + checkResult(response, "headObject"); + }) + // delete object + .flatMap((response) -> Mono.just(client.deleteObject( + software.amazon.awssdk.services.s3.model.DeleteObjectRequest.builder() + .bucket(properties.getBucket()) + .key(uploadState.objectKey) + .build() + ))) + .doOnNext((response) -> checkResult(response, "deleteObject")) + .then(); + }, + SdkAutoCloseable::close) + .onErrorMap(S3ExceptionHandler::map); + } + + private Flux readImage() { + DefaultResourceLoader resourceLoader = new DefaultResourceLoader(this.getClass() + .getClassLoader()); + String path = PathUtils.combinePath("validation.jpg"); + String simplifyPath = StringUtils.cleanPath(path); + Resource resource = resourceLoader.getResource(simplifyPath); + return DataBufferUtils.read(resource, new DefaultDataBufferFactory(), 1024); + } +} diff --git a/src/main/java/run/halo/s3os/S3OsAttachmentHandler.java b/src/main/java/run/halo/s3os/S3OsAttachmentHandler.java index ba3f3e9..c2db9c3 100644 --- a/src/main/java/run/halo/s3os/S3OsAttachmentHandler.java +++ b/src/main/java/run/halo/s3os/S3OsAttachmentHandler.java @@ -390,8 +390,8 @@ public class S3OsAttachmentHandler implements AttachmentHandler { SdkAutoCloseable::close); } - private Mono checkFileExistsAndRename(UploadState uploadState, - S3Client s3client) { + Mono checkFileExistsAndRename(UploadState uploadState, + S3Client s3client) { return Mono.defer(() -> { // deduplication of uploading files if (uploadingFile.put(uploadState.getUploadingMapKey(), @@ -437,8 +437,8 @@ public class S3OsAttachmentHandler implements AttachmentHandler { } - private Mono uploadPart(UploadState uploadState, ByteBuffer buffer, - S3Client s3client) { + Mono uploadPart(UploadState uploadState, ByteBuffer buffer, + S3Client s3client) { final int partNumber = ++uploadState.partCounter; return Mono.just(s3client.uploadPart(UploadPartRequest.builder() .bucket(uploadState.properties.getBucket()) @@ -457,7 +457,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler { }); } - private static void checkResult(SdkResponse result, String operation) { + static void checkResult(SdkResponse result, String operation) { log.info("operation: {}, result: {}", operation, result); if (result.sdkHttpResponse() == null || !result.sdkHttpResponse().isSuccessful()) { log.error("Failed to upload object, response: {}", result.sdkHttpResponse()); @@ -465,7 +465,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler { } } - private static ByteBuffer concatBuffers(List buffers) { + static ByteBuffer concatBuffers(List buffers) { int partSize = 0; for (DataBuffer b : buffers) { partSize += b.readableByteCount(); diff --git a/src/main/resources/extensions/policy-template-s3os.yaml b/src/main/resources/extensions/policy-template-s3os.yaml index ea0f05c..80c7c01 100644 --- a/src/main/resources/extensions/policy-template-s3os.yaml +++ b/src/main/resources/extensions/policy-template-s3os.yaml @@ -14,130 +14,134 @@ spec: forms: - group: default formSchema: - - $formkit: text - name: bucket - label: Bucket 桶名称 - validation: required - - $formkit: select - name: endpointProtocol - label: Endpoint 访问协议 - options: - - label: HTTPS - value: https - - label: HTTP - value: http - validation: required - - $formkit: select - name: enablePathStyleAccess - label: Endpoint 访问风格 - options: - - label: Virtual Hosted Style - value: false - - label: Path Style - value: true - value: false - validation: required - - $formkit: text - name: endpoint - label: EndPoint - placeholder: 请填写不带bucket-name的Endpoint - validation: required - help: 协议头请在上方设置,此处无需以"http://"或"https://"开头,系统会自动拼接 - - $formkit: password - name: accessKey - label: Access Key ID - placeholder: 存储桶用户标识(用户名) - validation: required - - $formkit: password - name: accessSecret - label: Access Key Secret - placeholder: 存储桶密钥(密码) - validation: required - - $formkit: text - name: region - label: Region - placeholder: 如不填写,则默认为"Auto" - help: 若Region为Auto无法使用,才需要填写对应Region - - $formkit: text - name: location - label: 上传目录 - placeholder: 如不填写,则默认上传到根目录 - help: 支持的占位符请查阅:https://github.com/halo-dev/plugin-s3#上传目录 - - $formkit: select - name: randomFilenameMode - label: 上传时重命名文件方式 - options: - - label: 保留原文件名 - value: none - - label: 自定义(请在下方输入自定义模板) - value: custom - - label: 使用UUID - value: uuid - - label: 使用毫秒时间戳 - value: timestampMs - - label: 使用原文件名 + 随机字母 - value: withString - - label: 使用日期 + 随机字母 - value: dateWithString - - label: 使用日期时间 + 随机字母 - value: datetimeWithString - - label: 使用随机字母 - value: string - validation: required - - $formkit: number - name: randomStringLength - key: randomStringLength - label: 随机字母长度 - min: 4 - max: 16 - if: "$randomFilenameMode == 'dateWithString' || $randomFilenameMode == 'datetimeWithString' || $randomFilenameMode == 'withString' || $randomFilenameMode == 'string'" - help: 支持4~16位, 默认为8位 - - $formkit: text - name: customTemplate - key: customTemplate - label: 自定义文件名模板 - if: "$randomFilenameMode == 'custom'" - value: "${origin-filename}" - help: 支持的占位符请查阅:https://github.com/halo-dev/plugin-s3#自定义文件名模板 - - $formkit: select - name: duplicateFilenameHandling - label: 重复文件名处理方式 - options: - - label: 加随机字母数字后缀 - value: randomAlphanumeric - - label: 加随机字母后缀 - value: randomAlphabetic - - label: 报错不上传 - value: exception - validation: required - - $formkit: select - name: protocol - label: 绑定域名协议 - options: - - label: HTTPS - value: https - - label: HTTP - value: http - validation: required - - $formkit: text - name: domain - label: 绑定域名(CDN域名) - placeholder: 如不设置,那么将使用 Bucket + EndPoint 作为域名 - help: 协议头请在上方设置,此处无需以"http://"或"https://"开头,系统会自动拼接 - - $formkit: repeater - name: urlSuffixes - label: 网址后缀 - help: 用于对指定文件类型的网址添加后缀处理参数,优先级从上到下只取第一个匹配项 - value: [ ] - min: 0 + - $formkit: verificationForm + action: "/apis/s3os.halo.run/v1alpha1/policies/s3/validation" + label: 对象存储验证 children: - $formkit: text - name: fileSuffix - label: 文件后缀 - placeholder: 以半角逗号分隔,例如:jpg,jpeg,png,gif + name: bucket + label: Bucket 桶名称 + validation: required + - $formkit: select + name: endpointProtocol + label: Endpoint 访问协议 + options: + - label: HTTPS + value: https + - label: HTTP + value: http + validation: required + - $formkit: select + name: enablePathStyleAccess + label: Endpoint 访问风格 + options: + - label: Virtual Hosted Style + value: false + - label: Path Style + value: true + value: false validation: required - $formkit: text - name: urlSuffix + name: endpoint + label: EndPoint + placeholder: 请填写不带bucket-name的Endpoint + validation: required + help: 协议头请在上方设置,此处无需以"http://"或"https://"开头,系统会自动拼接 + - $formkit: password + name: accessKey + label: Access Key ID + placeholder: 存储桶用户标识(用户名) + validation: required + - $formkit: password + name: accessSecret + label: Access Key Secret + placeholder: 存储桶密钥(密码) + validation: required + - $formkit: text + name: region + label: Region + placeholder: 如不填写,则默认为"Auto" + help: 若Region为Auto无法使用,才需要填写对应Region + - $formkit: text + name: location + label: 上传目录 + placeholder: 如不填写,则默认上传到根目录 + help: 支持的占位符请查阅:https://github.com/halo-dev/plugin-s3#上传目录 + - $formkit: select + name: randomFilenameMode + label: 上传时重命名文件方式 + options: + - label: 保留原文件名 + value: none + - label: 自定义(请在下方输入自定义模板) + value: custom + - label: 使用UUID + value: uuid + - label: 使用毫秒时间戳 + value: timestampMs + - label: 使用原文件名 + 随机字母 + value: withString + - label: 使用日期 + 随机字母 + value: dateWithString + - label: 使用日期时间 + 随机字母 + value: datetimeWithString + - label: 使用随机字母 + value: string + validation: required + - $formkit: number + name: randomStringLength + key: randomStringLength + label: 随机字母长度 + min: 4 + max: 16 + if: "$randomFilenameMode == 'dateWithString' || $randomFilenameMode == 'datetimeWithString' || $randomFilenameMode == 'withString' || $randomFilenameMode == 'string'" + help: 支持4~16位, 默认为8位 + - $formkit: text + name: customTemplate + key: customTemplate + label: 自定义文件名模板 + if: "$randomFilenameMode == 'custom'" + value: "${origin-filename}" + help: 支持的占位符请查阅:https://github.com/halo-dev/plugin-s3#自定义文件名模板 + - $formkit: select + name: duplicateFilenameHandling + label: 重复文件名处理方式 + options: + - label: 加随机字母数字后缀 + value: randomAlphanumeric + - label: 加随机字母后缀 + value: randomAlphabetic + - label: 报错不上传 + value: exception + validation: required + - $formkit: select + name: protocol + label: 绑定域名协议 + options: + - label: HTTPS + value: https + - label: HTTP + value: http + validation: required + - $formkit: text + name: domain + label: 绑定域名(CDN域名) + placeholder: 如不设置,那么将使用 Bucket + EndPoint 作为域名 + help: 协议头请在上方设置,此处无需以"http://"或"https://"开头,系统会自动拼接 + - $formkit: repeater + name: urlSuffixes label: 网址后缀 - placeholder: 例如:?imageMogr2/format/webp - validation: required \ No newline at end of file + help: 用于对指定文件类型的网址添加后缀处理参数,优先级从上到下只取第一个匹配项 + value: [ ] + min: 0 + children: + - $formkit: text + name: fileSuffix + label: 文件后缀 + placeholder: 以半角逗号分隔,例如:jpg,jpeg,png,gif + validation: required + - $formkit: text + name: urlSuffix + label: 网址后缀 + placeholder: 例如:?imageMogr2/format/webp + validation: required \ No newline at end of file diff --git a/src/main/resources/extensions/s3os-role-template.yaml b/src/main/resources/extensions/s3os-role-template.yaml index 51dcc0d..271f321 100644 --- a/src/main/resources/extensions/s3os-role-template.yaml +++ b/src/main/resources/extensions/s3os-role-template.yaml @@ -39,3 +39,16 @@ rules: - apiGroups: [ "s3os.halo.run" ] resources: [ "attachments" ] verbs: [ "delete" ] +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-s3os-policy-config-validation + labels: + halo.run/role-template: "true" + rbac.authorization.halo.run/aggregate-to-role-template-manage-configmaps: "true" +rules: + - apiGroups: ["s3os.halo.run"] + resources: ["policies/validation"] + resourceNames: ["s3"] + verbs: [ "create" ] \ No newline at end of file diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index b81feb1..fb81345 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -4,7 +4,7 @@ metadata: name: PluginS3ObjectStorage spec: enabled: true - requires: ">=2.13.0" + requires: ">=2.14.0" author: name: Halo OSS Team website: https://github.com/halo-dev diff --git a/src/main/resources/validation.jpg b/src/main/resources/validation.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8f93831d236edf97ef67c3562f080c7e1af7f9fc GIT binary patch literal 17896 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 I#K+421&?@w`~Uy| literal 0 HcmV?d00001