diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/AbstractS3TargetConnector.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/AbstractS3TargetConnector.java index d75f32c5d..53feb3117 100644 --- a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/AbstractS3TargetConnector.java +++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/AbstractS3TargetConnector.java @@ -1,6 +1,7 @@ package org.dromara.sync.connector.s3; import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.utils.StringUtils; import org.dromara.common.json.utils.JsonUtils; @@ -23,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap; /** * 基于仓库原生 S3 客户端的目标连接器基础实现。 */ +@Slf4j public abstract class AbstractS3TargetConnector implements TargetConnector { private final Map clients = new ConcurrentHashMap<>(); @@ -30,6 +32,7 @@ public abstract class AbstractS3TargetConnector implements TargetConnector { @Override public ConnectorTestResult test(SyncConnection connection) { try { + S3SseSetting.from(parseJson(connection.getConfigJson())); client(connection).headBucket(connection.getBucketName()); return ConnectorTestResult.success("目标存储桶访问成功"); } catch (Exception e) { @@ -44,12 +47,36 @@ public abstract class AbstractS3TargetConnector implements TargetConnector { .setContentType(request.contentType()) .setMetadata(request.metadata()); SyncS3OssClient client = client(connection); - PutObjectResult result = client.upload(request.objectKey(), request.path(), options); + S3SseSetting sse = S3SseSetting.from(parseJson(connection.getConfigJson())); + PutObjectResult result = client.upload(connection.getBucketName(), request.objectKey(), + request.path(), options, sse); HeadObjectResponse head = client.headObject(connection.getBucketName(), result.key()); + verifyEncryption(sse, head, result.key()); return new TargetWriteResult(result.key(), head.versionId(), head.eTag(), head.contentLength(), result.url(), head.metadata()); } + /** + * 校验服务端实际生效的加密方式与连接配置是否一致。 + * + *

不同厂商对加密请求头的支持程度不一致,部分服务端会静默忽略。此处仅告警不中断同步, + * 便于在日志中及时发现目标端不支持所选加密方式的情况。

+ * + * @param sse 期望的加密设置 + * @param head 对象元信息 + * @param objectKey 对象键 + */ + private void verifyEncryption(S3SseSetting sse, HeadObjectResponse head, String objectKey) { + if (!sse.enabled()) { + return; + } + String actual = head.serverSideEncryptionAsString(); + if (!sse.expectedAlgorithm().equals(actual)) { + log.warn("目标端未按配置应用服务端加密,期望={},实际={},objectKey={}", + sse.expectedAlgorithm(), actual == null ? "未加密" : actual, objectKey); + } + } + @Override public boolean exists(SyncConnection connection, String objectKey) { return client(connection).objectExists(connection.getBucketName(), objectKey); diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/S3SseSetting.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/S3SseSetting.java new file mode 100644 index 000000000..08df58ae5 --- /dev/null +++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/S3SseSetting.java @@ -0,0 +1,112 @@ +package org.dromara.sync.connector.s3; + +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.sync.constant.SyncConstants; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.ServerSideEncryption; + +import java.util.List; +import java.util.Map; + +/** + * 目标端服务端加密(SSE)设置。 + * + *

加密方式由连接扩展配置的 {@code sseMode} 决定,取值为 + * {@link SyncConstants#SSE_NONE}、{@link SyncConstants#SSE_S3} 或 {@link SyncConstants#SSE_KMS}。 + * 默认 {@code NONE} 表示不发送任何加密请求头,完全由存储桶自身的默认加密策略决定, + * 以保证对不支持 SSE 的服务端保持兼容。

+ * + * @param mode 加密方式 + * @param kmsKeyId KMS 密钥标识,仅 {@code SSE_KMS} 且需要指定密钥时使用 + */ +public record S3SseSetting(String mode, String kmsKeyId) { + + /** + * 支持的加密方式集合。 + */ + private static final List SUPPORTED_MODES = + List.of(SyncConstants.SSE_NONE, SyncConstants.SSE_S3, SyncConstants.SSE_KMS); + + /** + * 不指定加密方式,跟随存储桶默认策略。 + */ + public static final S3SseSetting NONE = new S3SseSetting(SyncConstants.SSE_NONE, null); + + /** + * 从连接扩展配置解析加密设置。 + * + * @param config 连接扩展配置 + * @return 加密设置,未配置时返回 {@link #NONE} + */ + public static S3SseSetting from(Map config) { + if (config == null || config.isEmpty()) { + return NONE; + } + String mode = normalize(config.get("sseMode")); + String kmsKeyId = StringUtils.trimToNull(stringValue(config.get("sseKmsKeyId"))); + if (StringUtils.isBlank(mode)) { + mode = SyncConstants.SSE_NONE; + } + if (!SUPPORTED_MODES.contains(mode)) { + throw new ServiceException("目标连接 sseMode 只支持 {}", String.join("、", SUPPORTED_MODES)); + } + if (kmsKeyId != null && !SyncConstants.SSE_KMS.equals(mode)) { + throw new ServiceException("仅 SSE_KMS 加密方式可以指定 sseKmsKeyId"); + } + return new S3SseSetting(mode, kmsKeyId); + } + + /** + * 是否需要显式发送服务端加密请求头。 + * + * @return 需要发送返回 {@code true} + */ + public boolean enabled() { + return !SyncConstants.SSE_NONE.equals(mode); + } + + /** + * 将加密设置应用到上传请求。 + * + *

{@code NONE} 不写入任何加密请求头,由服务端按存储桶默认策略处理。

+ * + * @param builder PutObject 请求构建器 + */ + public void applyTo(PutObjectRequest.Builder builder) { + switch (mode) { + case SyncConstants.SSE_S3 -> builder.serverSideEncryption(ServerSideEncryption.AES256); + case SyncConstants.SSE_KMS -> { + builder.serverSideEncryption(ServerSideEncryption.AWS_KMS); + if (StringUtils.isNotBlank(kmsKeyId)) { + builder.ssekmsKeyId(kmsKeyId); + } + } + default -> { + // NONE 不发送加密请求头,保持对不支持 SSE 的服务端的兼容。 + } + } + } + + /** + * 获取期望的服务端加密算法标识,用于回写校验。 + * + * @return 加密算法标识;{@code NONE} 返回 {@code null} + */ + public String expectedAlgorithm() { + return switch (mode) { + case SyncConstants.SSE_S3 -> ServerSideEncryption.AES256.toString(); + case SyncConstants.SSE_KMS -> ServerSideEncryption.AWS_KMS.toString(); + default -> null; + }; + } + + private static String normalize(Object value) { + String text = stringValue(value); + return text == null ? null : text.trim().toUpperCase(); + } + + private static String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } +} diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/SyncS3OssClient.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/SyncS3OssClient.java index f023a0169..6005d7cb1 100644 --- a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/SyncS3OssClient.java +++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/connector/s3/SyncS3OssClient.java @@ -2,13 +2,22 @@ package org.dromara.sync.connector.s3; import org.dromara.common.oss.client.DefaultOssClientImpl; import org.dromara.common.oss.config.OssClientConfig; +import org.dromara.common.oss.exception.S3StorageException; +import org.dromara.common.oss.model.HandleAsyncResult; +import org.dromara.common.oss.model.Options; +import org.dromara.common.oss.model.PutObjectResult; +import software.amazon.awssdk.core.async.AsyncRequestBody; import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; import software.amazon.awssdk.services.s3.model.S3Exception; +import java.nio.file.Path; +import java.util.Optional; import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; /** - * 为同步连接器补充只读的存储桶连通性探测。 + * 为同步连接器补充只读的存储桶连通性探测与服务端加密上传。 */ class SyncS3OssClient extends DefaultOssClientImpl { @@ -41,4 +50,64 @@ class SyncS3OssClient extends DefaultOssClientImpl { throw e; } } + + /** + * 上传本地文件并按连接配置附加服务端加密请求头。 + * + *

公共 OSS 模块的 {@link Options} 不支持服务端加密,因此这里直接使用底层 + * {@code doCustomUpload} 自行构建 PutObject 请求,避免修改公共模块 API。

+ * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param path 待上传文件 + * @param options 上传选项 + * @param sse 服务端加密设置 + * @return 上传结果 + */ + PutObjectResult upload(String bucket, String key, Path path, Options options, S3SseSetting sse) { + AsyncRequestBody body = AsyncRequestBody.fromFile(path); + // 与公共模块 bucketUpload 保持一致:请求体自带的长度与类型优先于可选项。 + Long contentLength = body.contentLength().orElse(options.getLength()); + String contentType = options.getContentType() == null || options.getContentType().isBlank() + ? body.contentType() : options.getContentType(); + HandleAsyncResult result = doCustomUpload(body, builder -> { + builder.bucket(bucket) + .key(key) + .contentMD5(options.getMd5Digest()) + .contentType(contentType) + .contentLength(contentLength) + .metadata(options.getMetadata()); + sse.applyTo(builder); + }, options.getTransferListeners()); + if (result.isFailure()) { + throw toStorageException(result.error()); + } + Optional opt = result.getResult(); + if (opt.isEmpty()) { + throw S3StorageException.form("response is empty."); + } + PutObjectResponse response = opt.get(); + Long size = response.size(); + if (size == null) { + size = contentLength == null ? 0 : contentLength; + } + return PutObjectResult.form("%s/%s".formatted(config.getBucketUrl(bucket), key), key, response.eTag(), size); + } + + /** + * 转换为统一的 S3 存储异常。 + * + * @param e 原始异常 + * @return S3 存储异常 + */ + private S3StorageException toStorageException(Throwable e) { + Throwable cause = e; + while ((cause instanceof CompletionException || cause instanceof ExecutionException) && cause.getCause() != null) { + cause = cause.getCause(); + } + if (cause instanceof S3StorageException ex) { + return ex; + } + return S3StorageException.form(cause); + } } diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/constant/SyncConstants.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/constant/SyncConstants.java index cd91a8294..562e6e456 100644 --- a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/constant/SyncConstants.java +++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/constant/SyncConstants.java @@ -32,4 +32,19 @@ public interface SyncConstants { String OBJECT_FILE = "FILE"; String OBJECT_FOLDER = "FOLDER"; String OBJECT_ONLINE_DOCUMENT = "ONLINE_DOCUMENT"; + + /** + * 目标端服务端加密方式:不指定,由存储桶默认策略决定。 + */ + String SSE_NONE = "NONE"; + + /** + * 目标端服务端加密方式:SSE-S3,由服务端托管密钥(AES256)。 + */ + String SSE_S3 = "SSE_S3"; + + /** + * 目标端服务端加密方式:SSE-KMS,由 KMS 托管密钥。 + */ + String SSE_KMS = "SSE_KMS"; } diff --git a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncConnectionServiceImpl.java b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncConnectionServiceImpl.java index b8b7cc753..c275156a1 100644 --- a/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncConnectionServiceImpl.java +++ b/ruoyi-modules/ruoyi-sync/src/main/java/org/dromara/sync/service/impl/SyncConnectionServiceImpl.java @@ -15,6 +15,7 @@ import org.dromara.sync.connector.ConnectorRegistry; import org.dromara.sync.connector.dingtalk.DwsAuthIdentity; import org.dromara.sync.connector.dingtalk.DwsAuthSessionManager; import org.dromara.sync.connector.model.ConnectorTestResult; +import org.dromara.sync.connector.s3.S3SseSetting; import org.dromara.sync.constant.SyncConstants; import org.dromara.sync.domain.SyncConnection; import org.dromara.sync.domain.SyncJob; @@ -328,6 +329,8 @@ public class SyncConnectionServiceImpl implements ISyncConnectionService { requireSecret(secrets, "accessKey", "目标连接 accessKey 不能为空"); requireSecret(secrets, "secretKey", "目标连接 secretKey 不能为空"); } + // 加密方式与 KMS 密钥组合的合法性由连接器统一定义,保存时提前拦截非法配置。 + S3SseSetting.from(parseOptionalJson(connection.getConfigJson())); } /**