From 061b9a53e799226f018e8b1d30ddc2c9bb666c7e Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Jul 2026 00:37:16 +0800 Subject: [PATCH] feat(update): implement signed RN release sets --- CLAUDE.md | 2 +- Jenkinsfile | 5 +- README.md | 58 +- docs/API_ACCESS.md | 17 +- .../controller/InternalSdkController.java | 118 +++- .../service/ConfigFileSigningService.java | 45 ++ .../service/InternalRequestAuthorizer.java | 38 ++ .../src/main/resources/application.yml | 2 +- .../controller/InternalSdkControllerTest.java | 9 +- .../ConfigFileSigningServiceDetachedTest.java | 31 + .../InternalRequestAuthorizerTest.java | 21 + update-service/pom.xml | 10 + .../update/config/RnBearerAuthFilter.java | 58 ++ .../xuqm/update/config/SecurityConfig.java | 12 +- .../update/controller/RnBundleController.java | 605 ++++++++---------- .../controller/UnifiedReleaseController.java | 46 +- .../controller/UpdateFileController.java | 29 +- .../xuqm/update/entity/RnBundleEntity.java | 160 +++-- .../update/model/RnBundleInspectResult.java | 11 - .../xuqm/update/model/RnBundlePackage.java | 22 + .../com/xuqm/update/model/RnBundleView.java | 62 ++ .../xuqm/update/model/RnReleaseManifest.java | 71 ++ .../update/model/RnReleaseSetRequest.java | 25 + .../update/model/RnReleaseSetResponse.java | 33 + .../update/model/UnifiedReleaseManifest.java | 13 +- .../update/model/UnifiedReleaseResult.java | 4 +- .../update/repository/RnBundleRepository.java | 5 +- .../update/service/InternalServiceToken.java | 14 + .../service/RnBundleAuthorizationService.java | 37 ++ .../service/RnBundlePackageService.java | 463 ++++++++++++++ .../update/service/RnReleaseSetService.java | 337 ++++++++++ .../update/service/RnVersionContract.java | 69 ++ .../service/TenantAppOwnershipClient.java | 56 ++ .../service/TenantReleaseManifestSigner.java | 72 +++ .../update/service/UpdateAssetService.java | 152 ----- .../src/main/resources/application.yml | 2 +- .../V3__rn_release_set_terminal_contract.sql | 23 + .../update/config/RnBearerAuthFilterTest.java | 42 ++ .../RnBundleControllerIdentityTest.java | 21 + .../controller/UpdateFileControllerTest.java | 65 ++ .../model/RnReleaseManifestVectorTest.java | 65 ++ .../service/InternalRnClientTokenTest.java | 37 ++ .../RnBundleAuthorizationServiceTest.java | 26 + .../service/RnBundlePackageServiceTest.java | 278 ++++++++ .../service/RnReleaseSetServiceTest.java | 161 +++++ .../update/service/RnVersionContractTest.java | 26 + .../resources/rn-package-manifest-v1.json | 26 + .../rn-release-signature-vector.json | 6 + .../src/test/resources/rn-semver-vector.json | 11 + 49 files changed, 2847 insertions(+), 654 deletions(-) create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/InternalRequestAuthorizer.java create mode 100644 tenant-service/src/test/java/com/xuqm/tenant/service/ConfigFileSigningServiceDetachedTest.java create mode 100644 tenant-service/src/test/java/com/xuqm/tenant/service/InternalRequestAuthorizerTest.java create mode 100644 update-service/src/main/java/com/xuqm/update/config/RnBearerAuthFilter.java delete mode 100644 update-service/src/main/java/com/xuqm/update/model/RnBundleInspectResult.java create mode 100644 update-service/src/main/java/com/xuqm/update/model/RnBundlePackage.java create mode 100644 update-service/src/main/java/com/xuqm/update/model/RnBundleView.java create mode 100644 update-service/src/main/java/com/xuqm/update/model/RnReleaseManifest.java create mode 100644 update-service/src/main/java/com/xuqm/update/model/RnReleaseSetRequest.java create mode 100644 update-service/src/main/java/com/xuqm/update/model/RnReleaseSetResponse.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/InternalServiceToken.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/RnBundleAuthorizationService.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/RnBundlePackageService.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/RnReleaseSetService.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/RnVersionContract.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/TenantAppOwnershipClient.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/TenantReleaseManifestSigner.java create mode 100644 update-service/src/main/resources/db/migration/V3__rn_release_set_terminal_contract.sql create mode 100644 update-service/src/test/java/com/xuqm/update/config/RnBearerAuthFilterTest.java create mode 100644 update-service/src/test/java/com/xuqm/update/controller/RnBundleControllerIdentityTest.java create mode 100644 update-service/src/test/java/com/xuqm/update/controller/UpdateFileControllerTest.java create mode 100644 update-service/src/test/java/com/xuqm/update/model/RnReleaseManifestVectorTest.java create mode 100644 update-service/src/test/java/com/xuqm/update/service/InternalRnClientTokenTest.java create mode 100644 update-service/src/test/java/com/xuqm/update/service/RnBundleAuthorizationServiceTest.java create mode 100644 update-service/src/test/java/com/xuqm/update/service/RnBundlePackageServiceTest.java create mode 100644 update-service/src/test/java/com/xuqm/update/service/RnReleaseSetServiceTest.java create mode 100644 update-service/src/test/java/com/xuqm/update/service/RnVersionContractTest.java create mode 100644 update-service/src/test/resources/rn-package-manifest-v1.json create mode 100644 update-service/src/test/resources/rn-release-signature-vector.json create mode 100644 update-service/src/test/resources/rn-semver-vector.json diff --git a/CLAUDE.md b/CLAUDE.md index f983149..7c97517 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ cd xuqm-bugcollect-service && mvn spring-boot:run & ### update-service(:8084) - App 更新:`/api/v1/updates/app/check`、`/api/v1/updates/app/upload` -- RN Bundle:`/api/v1/rn/update/check`、`/api/v1/rn/upload` +- RN 发布集合:`/api/v1/rn/release-set/check`;管理端上传:`/api/v1/rn/upload` ### xuqm-bugcollect-service(:9006) diff --git a/Jenkinsfile b/Jenkinsfile index 6885201..e1863d8 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -70,12 +70,15 @@ pipeline { withCredentials([ string(credentialsId: 'SDK_CONFIG_SIGNING_KEY_ID', variable: 'CONFIG_KEY_ID'), string(credentialsId: 'SDK_CONFIG_SIGNING_PRIVATE_KEY_BASE64', variable: 'CONFIG_PRIVATE_KEY'), - string(credentialsId: 'SDK_CONFIG_SIGNING_PUBLIC_KEY_BASE64', variable: 'CONFIG_PUBLIC_KEY') + string(credentialsId: 'SDK_CONFIG_SIGNING_PUBLIC_KEY_BASE64', variable: 'CONFIG_PUBLIC_KEY'), + string(credentialsId: 'SDK_INTERNAL_TOKEN', variable: 'INTERNAL_TOKEN') ]) { bat ''' if "%CONFIG_KEY_ID%"=="" exit /b 1 if "%CONFIG_PRIVATE_KEY%"=="" exit /b 1 if "%CONFIG_PUBLIC_KEY%"=="" exit /b 1 + if "%INTERNAL_TOKEN%"=="" exit /b 1 + if "%INTERNAL_TOKEN%"=="xuqm-internal-token" exit /b 1 ''' } } diff --git a/README.md b/README.md index 39a6837..d0f91b7 100644 --- a/README.md +++ b/README.md @@ -489,46 +489,46 @@ POST /api/v1/updates/app/upload | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/v1/rn/update/check` | 检查 RN Bundle 更新 | -| POST | `/api/v1/rn/upload` | 上传 Bundle 文件 | +| POST | `/api/v1/rn/release-set/check` | 按依赖闭包检查不可拆分的发布集合 | +| POST | `/api/v1/rn/upload` | 上传 CLI 生成的 `.xuqm.zip` | | POST | `/api/v1/rn/{id}/publish` | 发布 Bundle | +| POST | `/api/v1/rn/{id}/unpublish` | 下架 Bundle | -**检查 RN 更新** -``` -GET /api/v1/rn/update/check - ?appKey=ak_xxx - &moduleId=main - &platform=android - ¤tVersion=1.0.0 -``` - -响应(有更新): +**检查 RN 发布集合** ```json { - "data": { - "needsUpdate": true, - "latestVersion": "1.1.0", - "downloadUrl": "http://...", - "md5": "abc123...", - "minCommonVersion": "1.0.0", - "note": "热更新说明" - } + "appKey": "ak_xxx", + "targetModuleId": "buz-home", + "platform": "ANDROID", + "userId": "user-1", + "appVersion": "8.0.0", + "nativeApiLevel": 2, + "nativeBaselineId": "android-sha256:abc", + "installedModules": [ + {"moduleId": "common", "type": "common", "version": "1.0.0"}, + {"moduleId": "buz-home", "type": "buz", "version": "1.0.0"} + ] } ``` -**上传 Bundle(multipart/form-data)** +服务端返回一个 `releaseId` 以及必须原子安装的 `modules`。每个模块包含精确下载地址、 +整包 `sha256`、Bundle `bundleSha256`、完整签名清单、`keyId` 和 Ed25519 签名。 + +**上传发布包(multipart/form-data)** ``` POST /api/v1/rn/upload appKey=ak_xxx - moduleId=main - platform=ANDROID - version=1.1.0 - minCommonVersion=1.0.0 note=修复闪退 - bundle= + bundle= ``` -服务端自动计算 MD5,存储至 `{upload-dir}/rn/{appKey}/{platform}/{moduleId}/`。 +发布身份只取 ZIP 根目录的 `rn-manifest.json`。服务端重新计算内部 Bundle 与归档 +SHA-256,校验应用归属后,用租户平台 Config V2 的 Ed25519 信任根签署规范化清单。 +表单不得覆盖清单身份。历史 MD5 记录不会成为发布候选,必须重新上传。 + +CLI、服务端和原生解包层执行同一安全上限:归档不超过 50 MiB、单个 entry 与总解压 +内容分别不超过 100 MiB、entry 数不超过 4096。符号链接、目录 entry、危险路径、 +未声明资源、重复 JSON key 和未知清单字段均拒绝。 ### 环境变量配置 @@ -537,3 +537,7 @@ update: upload-dir: ${UPDATE_UPLOAD_DIR:/tmp/xuqm-update} base-url: ${UPDATE_BASE_URL:http://localhost:8084} ``` + +tenant-service 与 update-service 必须由部署平台注入同一份非空 +`SDK_INTERNAL_TOKEN`。缺失或仍为历史占位值时,应用归属校验和发布清单签名会在请求前 +关闭;令牌不得写入仓库、构建日志或安装包。 diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index 095a335..2b62471 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -166,10 +166,11 @@ | POST | `/api/v1/updates/app/{id}/publish` | 是 | 发布 App 版本 | | GET | `/api/v1/updates/app/list` | 是 | App 版本列表 | | GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK | -| GET | `/api/v1/rn/update/check` | 否 | 检查 RN 热更新 | -| POST | `/api/v1/rn/upload` | 是 | 上传 Bundle | +| POST | `/api/v1/rn/release-set/check` | 按应用发布配置 | 检查依赖闭包完整的 RN 发布集合 | +| POST | `/api/v1/rn/upload` | 是 | 上传 CLI 生成的 `.xuqm.zip` | | POST | `/api/v1/rn/{id}/publish` | 是 | 发布 Bundle | -| GET | `/api/v1/rn/files/{appKey}/{platform}/{moduleId}` | 否 | 下载 Bundle | +| POST | `/api/v1/rn/{id}/unpublish` | 是 | 下架 Bundle | +| GET | `/api/v1/rn/files/{id}/{appKey}/{platform}/{moduleId}` | 否 | 按发布记录精确下载归档 | ### tenant-service 提供给 update-sdk 的公共配置接口 @@ -188,7 +189,9 @@ - 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload` 和 `POST /api/v1/updates/app/inspect`。 - 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。 - `GET /api/v1/updates/app/check` 现在按“当前版本之后的最高已发布版本”返回更新信息,但 `forceUpdate` 会按照“当前版本之后是否存在任意一条强更版本”来计算,所以后续发布的非强更版本不会覆盖更低版本当时应看到的强更提示。 -- RN bundle 建议打成 zip 后再上传,zip 内至少包含 `rn-manifest.json`、bundle 文件和资源文件;update-service 会优先从 manifest 自动读取 `moduleId`、`version` / `bundleVersion`、`minCommonVersion` 和 `packageName`。 +- RN 发布只接受 CLI 生成的 `.xuqm.zip`。ZIP 根目录必须存在唯一的 `rn-manifest.json`;模块、平台、版本、整包范围、Common 范围、原生基线、构建 ID、Bundle 格式和哈希均由清单确定,管理端不能覆盖。 +- 服务端重新计算内部 Bundle 和整个归档的 SHA-256,并使用 Config V2 的 Ed25519 信任根签署规范化发布清单。历史 MD5 记录不会参与发布集合选择。 +- RN 归档协议统一限制为:压缩包 50 MiB、单 entry 100 MiB、总解压 100 MiB、最多 4096 entries;CLI、服务端与原生解包层必须使用相同数值。 - 租户平台里的“发布配置”标签页保存灰度默认模式、成员目录同步回调和成员选择回调;当默认模式切到成员灰度时,至少要配置一个回调才允许保存,保存前也会做连通性校验。 - 上下架、上传、发布、灰度、市场提交、商店配置变更都会写入 `update_operation_log`,可通过 `GET /api/v1/updates/ops/logs?appKey=...` 查询。 - 市场提交增加了批次级日志和逐市场阶段日志,常见 action 包括 `STORE_SUBMIT_REQUEST`、`STORE_SUBMIT_BATCH_START`、`STORE_SUBMIT_STORE_START`、`STORE_SUBMIT_STORE_STAGE`、`STORE_SUBMIT_STORE_SUCCESS`、`STORE_SUBMIT_STORE_FAILED`、`STORE_SUBMIT_BATCH_END`。 @@ -252,10 +255,12 @@ curl 'https://dev.xuqinmin.com/api/v1/updates/app/check?appKey=ak_demo_chat&plat Harmony 平台只跳转应用市场,不提供本地安装包下载。 -### RN 热更新检查 +### RN 发布集合检查 ```bash -curl 'https://dev.xuqinmin.com/api/v1/rn/update/check?appKey=ak_demo_chat&platform=ANDROID&moduleId=chat-home¤tVersion=1.0.0' +curl -X POST 'https://dev.xuqinmin.com/api/v1/rn/release-set/check' \ + -H 'Content-Type: application/json' \ + -d '{"appKey":"ak_demo_chat","targetModuleId":"chat-home","platform":"ANDROID","appVersion":"8.0.0","nativeApiLevel":2,"nativeBaselineId":"android-sha256:abc","installedModules":[{"moduleId":"common","type":"common","version":"1.0.0"},{"moduleId":"chat-home","type":"buz","version":"1.0.0"}]}' ``` ### IM 登录 diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java index ffa06ca..ca28bd0 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java @@ -6,7 +6,10 @@ import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.service.SdkAppProvisioningService; import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.ApiKeyService; -import org.springframework.beans.factory.annotation.Value; +import com.xuqm.tenant.service.ConfigFileSigningService; +import com.xuqm.tenant.service.InternalRequestAuthorizer; +import com.xuqm.tenant.repository.AppRepository; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -18,6 +21,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.Map; +import java.util.Set; +import java.util.TreeMap; @RestController @RequestMapping("/api/internal/sdk") @@ -26,23 +31,32 @@ public class InternalSdkController { private final SdkAppProvisioningService provisioningService; private final FeatureServiceManager featureServiceManager; private final ApiKeyService apiKeyService; - - @Value("${sdk.internal-token:xuqm-internal-token}") - private String internalToken; + private final ConfigFileSigningService configFileSigningService; + private final AppRepository appRepository; + private final ObjectMapper objectMapper; + private final InternalRequestAuthorizer internalRequestAuthorizer; public InternalSdkController(SdkAppProvisioningService provisioningService, FeatureServiceManager featureServiceManager, - ApiKeyService apiKeyService) { + ApiKeyService apiKeyService, + ConfigFileSigningService configFileSigningService, + AppRepository appRepository, + ObjectMapper objectMapper, + InternalRequestAuthorizer internalRequestAuthorizer) { this.provisioningService = provisioningService; this.featureServiceManager = featureServiceManager; this.apiKeyService = apiKeyService; + this.configFileSigningService = configFileSigningService; + this.appRepository = appRepository; + this.objectMapper = objectMapper; + this.internalRequestAuthorizer = internalRequestAuthorizer; } @GetMapping("/apps/{appKey}/secret") public ResponseEntity>> getAppSecret( @PathVariable String appKey, @RequestHeader(value = "X-Internal-Token", required = false) String token) { - if (token == null || !internalToken.equals(token)) { + if (!internalRequestAuthorizer.isAuthorized(token)) { return ResponseEntity.status(403) .body(ApiResponse.error(403, "Forbidden")); } @@ -57,7 +71,7 @@ public class InternalSdkController { public ResponseEntity>> getPlatformInfo( @PathVariable String appKey, @RequestHeader(value = "X-Internal-Token", required = false) String token) { - if (token == null || !internalToken.equals(token)) { + if (!internalRequestAuthorizer.isAuthorized(token)) { return ResponseEntity.status(403) .body(ApiResponse.error(403, "Forbidden")); } @@ -76,7 +90,7 @@ public class InternalSdkController { @PathVariable FeatureServiceEntity.Platform platform, @PathVariable FeatureServiceEntity.ServiceType serviceType, @RequestHeader(value = "X-Internal-Token", required = false) String token) { - if (token == null || !internalToken.equals(token)) { + if (!internalRequestAuthorizer.isAuthorized(token)) { return ResponseEntity.status(403) .body(ApiResponse.error(403, "Forbidden")); } @@ -99,7 +113,7 @@ public class InternalSdkController { public ResponseEntity>> validateApiKey( @RequestBody ApiKeyValidationRequest request, @RequestHeader(value = "X-Internal-Token", required = false) String token) { - if (token == null || !internalToken.equals(token)) { + if (!internalRequestAuthorizer.isAuthorized(token)) { return ResponseEntity.status(403) .body(ApiResponse.error(403, "Forbidden")); } @@ -113,4 +127,90 @@ public class InternalSdkController { public record ApiKeyValidationRequest(String apiKey) { } + + @PostMapping("/release-manifest/sign") + public ResponseEntity> signReleaseManifest( + @RequestBody ReleaseManifestSignRequest request, + @RequestHeader(value = "X-Internal-Token", required = false) String token) { + if (!internalRequestAuthorizer.isAuthorized(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + validateReleaseManifestAppIdentity(request == null ? null : request.canonicalPayload()); + return ResponseEntity.ok(ApiResponse.success( + configFileSigningService.signDetached(request == null ? null : request.canonicalPayload()))); + } + + @PostMapping("/apps/ownership/check") + public ResponseEntity>> checkAppOwnership( + @RequestBody AppOwnershipRequest request, + @RequestHeader(value = "X-Internal-Token", required = false) String token) { + if (!internalRequestAuthorizer.isAuthorized(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + boolean owned = request != null && request.appKey() != null && request.tenantId() != null + && appRepository.findByAppKey(request.appKey()) + .map(app -> request.tenantId().equals(app.getTenantId())) + .orElse(false); + return ResponseEntity.ok(ApiResponse.success(Map.of("owned", owned))); + } + + public record ReleaseManifestSignRequest(String canonicalPayload) { + } + + public record AppOwnershipRequest(String appKey, String tenantId) { + } + + private void validateReleaseManifestAppIdentity(String canonicalPayload) { + try { + var manifest = objectMapper.readTree(canonicalPayload); + if (manifest == null || !manifest.isObject()) { + throw new IllegalArgumentException("Release canonical manifest must be an object"); + } + Set required = Set.of( + "appKey", "archiveSha256", "packageName", "platform", "moduleId", "type", "version", + "appVersionRange", "builtAgainstNativeBaselineId", "buildId", "bundleFormat", + "minNativeApiLevel", "bundleSha256"); + java.util.Set actual = new java.util.HashSet<>(); + manifest.fieldNames().forEachRemaining(actual::add); + java.util.Set allowed = new java.util.HashSet<>(required); + allowed.add("commonVersionRange"); + if (!actual.containsAll(required) || !allowed.containsAll(actual)) { + throw new IllegalArgumentException("Release canonical manifest fields are invalid"); + } + Map sorted = new TreeMap<>(); + manifest.fields().forEachRemaining(entry -> { + if (!entry.getValue().isNull()) { + sorted.put(entry.getKey(), objectMapper.convertValue(entry.getValue(), Object.class)); + } + }); + if (!canonicalPayload.equals(objectMapper.writeValueAsString(sorted))) { + throw new IllegalArgumentException("Release manifest is not canonical JSON"); + } + String type = manifest.path("type").asText(""); + if ("common".equals(type) && manifest.has("commonVersionRange")) { + throw new IllegalArgumentException("common release must omit commonVersionRange"); + } + if (("app".equals(type) || "buz".equals(type)) + && !manifest.hasNonNull("commonVersionRange")) { + throw new IllegalArgumentException("app/buz release requires commonVersionRange"); + } + String appKey = manifest.path("appKey").asText(""); + String platform = manifest.path("platform").asText(""); + String packageName = manifest.path("packageName").asText(""); + AppEntity app = appRepository.findByAppKey(appKey) + .orElseThrow(() -> new IllegalArgumentException("Release appKey does not exist")); + String expectedPackage = switch (platform) { + case "ANDROID" -> app.getPackageName(); + case "IOS" -> app.getIosBundleId(); + default -> throw new IllegalArgumentException("Release platform is invalid"); + }; + if (expectedPackage == null || !expectedPackage.equals(packageName)) { + throw new IllegalArgumentException("Release packageName does not belong to appKey"); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("Release canonical manifest is invalid", e); + } + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/ConfigFileSigningService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/ConfigFileSigningService.java index 41797f9..5561dea 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/ConfigFileSigningService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/ConfigFileSigningService.java @@ -7,8 +7,10 @@ import org.springframework.stereotype.Service; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.PublicKey; +import java.security.Signature; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; +import java.nio.charset.StandardCharsets; import java.util.Base64; /** @@ -45,6 +47,49 @@ public class ConfigFileSigningService { return payload; } + /** + * 使用 Config V2 同一平台信任根签署不可变发布清单,不做加密。 + */ + public DetachedSignature signDetached(String canonicalPayload) { + if (canonicalPayload == null || canonicalPayload.isBlank()) { + throw new IllegalArgumentException("canonical payload is required"); + } + if (canonicalPayload.length() > 64 * 1024) { + throw new IllegalArgumentException("canonical payload is too large"); + } + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(privateKey); + signer.update(canonicalPayload.getBytes(StandardCharsets.UTF_8)); + return new DetachedSignature( + keyId, + Base64.getUrlEncoder().withoutPadding().encodeToString(signer.sign())); + } catch (Exception e) { + throw new IllegalStateException("Failed to sign release manifest", e); + } + } + + public void verifyDetached(String canonicalPayload, String candidateKeyId, String encodedSignature) { + if (!keyId.equals(candidateKeyId)) { + throw new IllegalArgumentException("Release manifest signing key is not trusted"); + } + try { + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(canonicalPayload.getBytes(StandardCharsets.UTF_8)); + if (!verifier.verify(Base64.getUrlDecoder().decode(encodedSignature))) { + throw new IllegalArgumentException("Release manifest signature verification failed"); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("Invalid release manifest signature", e); + } + } + + public record DetachedSignature(String keyId, String signature) { + } + private static PrivateKey readPrivateKey(String encoded) { try { return KeyFactory.getInstance("Ed25519") diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/InternalRequestAuthorizer.java b/tenant-service/src/main/java/com/xuqm/tenant/service/InternalRequestAuthorizer.java new file mode 100644 index 0000000..308f5a7 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/InternalRequestAuthorizer.java @@ -0,0 +1,38 @@ +package com.xuqm.tenant.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 内部服务请求的唯一鉴权入口。 + * + *

缺少配置或仍使用历史公开占位值时全部拒绝,避免内部签名能力在错误部署配置下开放。 + * 比较使用固定时间实现,且不会把令牌写入日志或异常。

+ */ +@Component +public final class InternalRequestAuthorizer { + + public static final String LEGACY_PLACEHOLDER = "xuqm-internal-token"; + + private final byte[] expectedToken; + + public InternalRequestAuthorizer(@Value("${sdk.internal-token:}") String configuredToken) { + expectedToken = usable(configuredToken) + ? configuredToken.getBytes(StandardCharsets.UTF_8) + : null; + } + + public boolean isAuthorized(String candidateToken) { + if (expectedToken == null || !usable(candidateToken)) { + return false; + } + return MessageDigest.isEqual( + expectedToken, candidateToken.getBytes(StandardCharsets.UTF_8)); + } + + private static boolean usable(String token) { + return token != null && !token.isBlank() && !LEGACY_PLACEHOLDER.equals(token); + } +} diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index 3efd497..d868648 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -93,7 +93,7 @@ management: include: health,info sdk: - internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} + internal-token: ${SDK_INTERNAL_TOKEN:} bootstrap-app-key: ${SDK_BOOTSTRAP_APP_KEY:ak_demo_chat} bootstrap-app-name: ${SDK_BOOTSTRAP_APP_NAME:Demo Chat} bootstrap-app-package: ${SDK_BOOTSTRAP_APP_PACKAGE:com.xuqm.demo} diff --git a/tenant-service/src/test/java/com/xuqm/tenant/controller/InternalSdkControllerTest.java b/tenant-service/src/test/java/com/xuqm/tenant/controller/InternalSdkControllerTest.java index 28cd34d..c6db89c 100644 --- a/tenant-service/src/test/java/com/xuqm/tenant/controller/InternalSdkControllerTest.java +++ b/tenant-service/src/test/java/com/xuqm/tenant/controller/InternalSdkControllerTest.java @@ -7,9 +7,9 @@ import static org.mockito.Mockito.when; import com.xuqm.tenant.service.ApiKeyService; import com.xuqm.tenant.service.FeatureServiceManager; +import com.xuqm.tenant.service.InternalRequestAuthorizer; import com.xuqm.tenant.service.SdkAppProvisioningService; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; class InternalSdkControllerTest { @@ -20,8 +20,11 @@ class InternalSdkControllerTest { InternalSdkController controller = new InternalSdkController( mock(SdkAppProvisioningService.class), mock(FeatureServiceManager.class), - apiKeyService); - ReflectionTestUtils.setField(controller, "internalToken", "internal-only"); + apiKeyService, + mock(com.xuqm.tenant.service.ConfigFileSigningService.class), + mock(com.xuqm.tenant.repository.AppRepository.class), + new com.fasterxml.jackson.databind.ObjectMapper(), + new InternalRequestAuthorizer("internal-only")); var response = controller.validateApiKey( new InternalSdkController.ApiKeyValidationRequest("secret-api-token"), diff --git a/tenant-service/src/test/java/com/xuqm/tenant/service/ConfigFileSigningServiceDetachedTest.java b/tenant-service/src/test/java/com/xuqm/tenant/service/ConfigFileSigningServiceDetachedTest.java new file mode 100644 index 0000000..51c4d4b --- /dev/null +++ b/tenant-service/src/test/java/com/xuqm/tenant/service/ConfigFileSigningServiceDetachedTest.java @@ -0,0 +1,31 @@ +package com.xuqm.tenant.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.KeyPairGenerator; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class ConfigFileSigningServiceDetachedTest { + + @Test + void signsCanonicalBytesAndRejectsTamperingOrUnknownRotationKey() throws Exception { + var keys = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + ConfigFileSigningService service = new ConfigFileSigningService( + "release-key-2026", + Base64.getEncoder().encodeToString(keys.getPrivate().getEncoded()), + Base64.getEncoder().encodeToString(keys.getPublic().getEncoded())); + String canonical = "{\"appKey\":\"ak_test\",\"moduleId\":\"common\"}"; + + var signed = service.signDetached(canonical); + service.verifyDetached(canonical, signed.keyId(), signed.signature()); + + assertThatThrownBy(() -> service.verifyDetached( + canonical.replace("common", "app"), signed.keyId(), signed.signature())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.verifyDetached( + canonical, "retired-key", signed.signature())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not trusted"); + } +} diff --git a/tenant-service/src/test/java/com/xuqm/tenant/service/InternalRequestAuthorizerTest.java b/tenant-service/src/test/java/com/xuqm/tenant/service/InternalRequestAuthorizerTest.java new file mode 100644 index 0000000..690683c --- /dev/null +++ b/tenant-service/src/test/java/com/xuqm/tenant/service/InternalRequestAuthorizerTest.java @@ -0,0 +1,21 @@ +package com.xuqm.tenant.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class InternalRequestAuthorizerTest { + + @Test + void rejectsMissingPlaceholderAndWrongTokenButAcceptsExactConfiguredSecret() { + assertThat(new InternalRequestAuthorizer("").isAuthorized("anything")).isFalse(); + assertThat(new InternalRequestAuthorizer("xuqm-internal-token") + .isAuthorized("xuqm-internal-token")).isFalse(); + + InternalRequestAuthorizer authorizer = new InternalRequestAuthorizer("real-internal-secret"); + assertThat(authorizer.isAuthorized(null)).isFalse(); + assertThat(authorizer.isAuthorized("")).isFalse(); + assertThat(authorizer.isAuthorized("wrong-internal-secret")).isFalse(); + assertThat(authorizer.isAuthorized("real-internal-secret")).isTrue(); + } +} diff --git a/update-service/pom.xml b/update-service/pom.xml index de17078..3dd298d 100644 --- a/update-service/pom.xml +++ b/update-service/pom.xml @@ -67,6 +67,16 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 + + org.semver4j + semver4j + 6.0.0 + + + org.apache.commons + commons-compress + 1.28.0 + com.mysql mysql-connector-j diff --git a/update-service/src/main/java/com/xuqm/update/config/RnBearerAuthFilter.java b/update-service/src/main/java/com/xuqm/update/config/RnBearerAuthFilter.java new file mode 100644 index 0000000..364bf73 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/config/RnBearerAuthFilter.java @@ -0,0 +1,58 @@ +package com.xuqm.update.config; + +import com.xuqm.common.security.ApiKeyValidator; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * RN 管理接口统一使用 Authorization Bearer。 + * + *

先按 CI API Token 校验;不是 API Token 时交给后续 JwtAuthFilter。X-API-Key 在 RN + * 命名空间中永不生效,因此不会覆盖已绑定的 appKey。

+ */ +public class RnBearerAuthFilter extends OncePerRequestFilter { + + private final ApiKeyValidator validator; + + public RnBearerAuthFilter(ApiKeyValidator validator) { + this.validator = validator; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return !path.startsWith("/api/v1/rn/") + || path.equals("/api/v1/rn/release-set/check") + || path.startsWith("/api/v1/rn/files/"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + String token = authorization.substring(7).trim(); + if (!token.isBlank()) { + String appKey = validator.resolveAppKey(token); + if (appKey != null) { + var authentication = new UsernamePasswordAuthenticationToken( + "apikey:" + appKey, + null, + List.of(new SimpleGrantedAuthority("ROLE_API_KEY"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + } + filterChain.doFilter(request, response); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java index 90f4162..8a1ecaa 100644 --- a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java +++ b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java @@ -48,8 +48,7 @@ public class SecurityConfig { "/api/v1/updates/app/check", "/api/v1/updates/app/inspect", "/api/v1/updates/public/**", - "/api/v1/rn/update/check", - "/api/v1/rn/inspect", + "/api/v1/rn/release-set/check", "/api/v1/rn/files/**", "/files/apk/**", "/ws/updates/**" @@ -60,7 +59,14 @@ public class SecurityConfig { .authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED)) ) // API Key 认证(通用过滤器 + tenant-service 验证) - .addFilterBefore(new ApiKeyAuthFilter(apiKeyValidator), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore( + new ApiKeyAuthFilter( + apiKeyValidator, + request -> !request.getRequestURI().startsWith("/api/v1/rn/"), + false), + UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new RnBearerAuthFilter(apiKeyValidator), + UsernamePasswordAuthenticationFilter.class) // HMAC 签名验证(SDK 请求) .addFilterBefore(new AppSignatureAuthFilter(appSecretResolver), UsernamePasswordAuthenticationFilter.class) // JWT 认证 diff --git a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java index f6bf65d..6db1ce0 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java @@ -1,398 +1,361 @@ package com.xuqm.update.controller; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.RnBundleEntity; -import com.xuqm.update.model.RnBundleInspectResult; +import com.xuqm.update.model.RnBundlePackage; +import com.xuqm.update.model.RnBundleView; +import com.xuqm.update.model.RnReleaseManifest; +import com.xuqm.update.model.RnReleaseSetRequest; +import com.xuqm.update.model.RnReleaseSetResponse; import com.xuqm.update.repository.RnBundleRepository; -import com.xuqm.update.service.PublishConfigService; -import com.xuqm.update.service.UpdateOperationLogService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import com.xuqm.update.service.UpdateAssetService; -import com.xuqm.update.service.ImPushUserClient; import com.xuqm.update.service.GrayMemberService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.xuqm.update.service.PublishConfigService; +import com.xuqm.update.service.RnBundleAuthorizationService; +import com.xuqm.update.service.RnBundlePackageService; +import com.xuqm.update.service.RnReleaseSetService; +import com.xuqm.update.service.TenantReleaseManifestSigner; +import com.xuqm.update.service.UpdateOperationLogService; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/v1/rn") public class RnBundleController { - private static final Logger log = LoggerFactory.getLogger(RnBundleController.class); - - private final RnBundleRepository bundleRepository; - private final UpdateAssetService updateAssetService; + private final RnBundleRepository repository; + private final RnBundlePackageService packageService; + private final TenantReleaseManifestSigner manifestSigner; + private final RnReleaseSetService releaseSetService; + private final RnBundleAuthorizationService authorizationService; private final PublishConfigService publishConfigService; - private final UpdateOperationLogService operationLogService; - private final ImPushUserClient imPushUserClient; private final GrayMemberService grayMemberService; + private final UpdateOperationLogService operationLogService; private final ObjectMapper objectMapper; - @Value("${update.base-url:https://update.dev.xuqinmin.com}") - private String baseUrl; - - public RnBundleController(RnBundleRepository bundleRepository, - UpdateAssetService updateAssetService, - PublishConfigService publishConfigService, - UpdateOperationLogService operationLogService, - ImPushUserClient imPushUserClient, - GrayMemberService grayMemberService, - ObjectMapper objectMapper) { - this.bundleRepository = bundleRepository; - this.updateAssetService = updateAssetService; + public RnBundleController( + RnBundleRepository repository, + RnBundlePackageService packageService, + TenantReleaseManifestSigner manifestSigner, + RnReleaseSetService releaseSetService, + RnBundleAuthorizationService authorizationService, + PublishConfigService publishConfigService, + GrayMemberService grayMemberService, + UpdateOperationLogService operationLogService, + ObjectMapper objectMapper) { + this.repository = repository; + this.packageService = packageService; + this.manifestSigner = manifestSigner; + this.releaseSetService = releaseSetService; + this.authorizationService = authorizationService; this.publishConfigService = publishConfigService; - this.operationLogService = operationLogService; - this.imPushUserClient = imPushUserClient; this.grayMemberService = grayMemberService; + this.operationLogService = operationLogService; this.objectMapper = objectMapper; } - @GetMapping("/update/check") - public ResponseEntity>> checkUpdate( - @RequestParam String appKey, - @RequestParam String moduleId, - @RequestParam String platform, - @RequestParam String currentVersion, - @RequestParam(required = false) String packageName, - @RequestParam(required = false) String userId) { - - boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey); - if (!allowAnonymousCheck && (userId == null || userId.isBlank())) { - return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); - } - - RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase()); - Optional latest = bundleRepository - .findTopByAppKeyAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc( - appKey, moduleId, p, RnBundleEntity.PublishStatus.PUBLISHED); - - if (latest.isEmpty()) { - return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); - } - - RnBundleEntity b = latest.get(); - boolean needsUpdate = !b.getVersion().equals(currentVersion); - if (!allowAnonymousCheck && b.isGrayEnabled() && userId != null && !userId.isBlank()) { - if (!isInGrayRelease(b, userId)) { - needsUpdate = false; - } - } - return ResponseEntity.ok(ApiResponse.success(Map.of( - "needsUpdate", needsUpdate, - "bundleVersion", parseBundleVersion(b.getVersion()), - "latestVersion", b.getVersion(), - "downloadUrl", resolvePublicBaseUrl() + "/api/v1/rn/files/" + appKey + "/" + platform.toLowerCase() + "/" + moduleId, - "md5", b.getMd5(), - "minCommonVersion", b.getMinCommonVersion() != null ? b.getMinCommonVersion() : "0.0.0", - "note", b.getNote() != null ? b.getNote() : "", - "packageName", b.getPackageName() != null ? b.getPackageName() : "", - "packageMatched", packageName == null || packageName.isBlank() || b.getPackageName() == null || b.getPackageName().isBlank() - || b.getPackageName().equals(packageName) - ))); + @PostMapping("/release-set/check") + public ResponseEntity> checkReleaseSet( + @RequestBody RnReleaseSetRequest request) { + return ResponseEntity.ok(ApiResponse.success(releaseSetService.check(request))); } + /** + * 表单身份字段仅用于防止 CLI/文件混批;任何已提供字段都必须与 ZIP manifest 相等, + * 数据库身份始终来自 ZIP,不能由表单覆盖。 + */ @PostMapping("/upload") - public ResponseEntity> upload( + public ResponseEntity> upload( @RequestParam String appKey, @RequestParam(required = false) String moduleId, - @RequestParam(required = false) RnBundleEntity.Platform platform, + @RequestParam(required = false) String type, + @RequestParam(required = false) String platform, @RequestParam(required = false) String version, - @RequestParam(required = false) String minCommonVersion, + @RequestParam(required = false) String appVersionRange, + @RequestParam(required = false) String builtAgainstNativeBaselineId, + @RequestParam(required = false) String buildId, + @RequestParam(required = false) String bundleFormat, + @RequestParam(required = false) String commonVersionRange, + @RequestParam(required = false) Integer minNativeApiLevel, + @RequestParam(required = false) String bundleSha256, @RequestParam(required = false) String packageName, @RequestParam(required = false) String note, - @RequestParam MultipartFile bundle) throws Exception { - RnBundleInspectResult inspected = updateAssetService.inspectRnBundle(bundle); - String resolvedModuleId = hasText(moduleId) ? moduleId : inspected.moduleId(); - String resolvedVersion = hasText(version) ? version : inspected.version(); - String resolvedMinCommonVersion = hasText(minCommonVersion) ? minCommonVersion : inspected.minCommonVersion(); - String resolvedPackageName = hasText(packageName) ? packageName : inspected.packageName(); - RnBundleEntity.Platform resolvedPlatform = platform != null ? platform : parsePlatform(inspected.platform()); - if (!hasText(resolvedModuleId) || !hasText(resolvedVersion) || resolvedPlatform == null) { - throw new IllegalArgumentException("moduleId, version and platform are required or must be readable from the bundle name"); + @RequestParam MultipartFile bundle, + Authentication authentication) throws Exception { + authorizationService.requireOwned(authentication, appKey); + RnBundlePackage inspected = packageService.inspect(bundle); + String storedPath = null; + try { + requireEqual(moduleId, inspected.moduleId(), "moduleId"); + requireEqualIgnoreCase(type, inspected.moduleType().name(), "type"); + requireEqualIgnoreCase(platform, inspected.platform().name(), "platform"); + requireEqual(version, inspected.version(), "version"); + requireEqual(appVersionRange, inspected.appVersionRange(), "appVersionRange"); + requireEqual(builtAgainstNativeBaselineId, inspected.builtAgainstNativeBaselineId(), + "builtAgainstNativeBaselineId"); + requireEqual(buildId, inspected.buildId(), "buildId"); + requireEqual(bundleFormat, inspected.bundleFormat(), "bundleFormat"); + requireEqualNullable(commonVersionRange, inspected.commonVersionRange(), "commonVersionRange"); + if (minNativeApiLevel != null && minNativeApiLevel != inspected.minNativeApiLevel()) { + throw new IllegalArgumentException("request minNativeApiLevel does not match rn-manifest.json"); + } + requireEqualIgnoreCase(bundleSha256, inspected.bundleSha256(), "bundleSha256"); + requireEqual(packageName, inspected.packageName(), "packageName"); + + String id = UUID.randomUUID().toString(); + RnBundleEntity entity = toEntity(id, appKey, note, inspected); + String canonical = RnReleaseManifest.fromEntity(entity).canonicalJson(objectMapper); + TenantReleaseManifestSigner.SignedPayload signed = manifestSigner.sign(canonical); + entity.setSignedManifest(canonical); + entity.setSigningKeyId(signed.keyId()); + entity.setSignature(signed.signature()); + storedPath = packageService.persist(appKey, id, inspected); + entity.setBundleUrl(storedPath); + RnBundleEntity saved = repository.save(entity); + operationLogService.record( + saved.getAppKey(), "RN_BUNDLE", saved.getId(), "UPLOAD", null, + Map.of( + "moduleId", saved.getModuleId(), + "type", saved.getModuleType().name(), + "platform", saved.getPlatform().name(), + "version", saved.getVersion(), + "buildId", saved.getBuildId(), + "archiveSha256", saved.getArchiveSha256())); + return ResponseEntity.ok(ApiResponse.success(RnBundleView.from(saved))); + } catch (Exception e) { + if (storedPath != null) packageService.deleteStoredFile(storedPath); + throw e; + } finally { + packageService.deleteTemporary(inspected); } - UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle( - appKey, resolvedPlatform.name(), resolvedModuleId, bundle); - - RnBundleEntity entity = new RnBundleEntity(); - entity.setId(UUID.randomUUID().toString()); - entity.setAppKey(appKey); - entity.setModuleId(resolvedModuleId); - entity.setPlatform(resolvedPlatform); - entity.setVersion(resolvedVersion); - entity.setBundleUrl(stored.bundlePath()); - entity.setMd5(stored.md5()); - entity.setMinCommonVersion(resolvedMinCommonVersion); - entity.setPackageName(resolvedPackageName); - entity.setNote(note); - entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); - entity.setPublishMode("MANUAL"); - entity.setScheduledPublishAt(null); - entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); - entity.setGrayMemberIds(null); - entity.setCreatedAt(LocalDateTime.now()); - RnBundleEntity saved = bundleRepository.save(entity); - operationLogService.record( - saved.getAppKey(), - "RN_BUNDLE", - saved.getId(), - "UPLOAD", - null, - Map.of( - "moduleId", saved.getModuleId(), - "platform", saved.getPlatform().name(), - "version", saved.getVersion(), - "minCommonVersion", saved.getMinCommonVersion() == null ? "" : saved.getMinCommonVersion(), - "packageName", saved.getPackageName() == null ? "" : saved.getPackageName() - )); - return ResponseEntity.ok(ApiResponse.success(saved)); - } - - @RequestMapping(value = "/inspect", method = {RequestMethod.GET, RequestMethod.POST}) - public ResponseEntity> inspect(@RequestParam(required = false) MultipartFile bundle) throws Exception { - return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectRnBundle(bundle))); } @GetMapping("/list") - public ResponseEntity>> list( + public ResponseEntity>> list( @RequestParam String appKey, @RequestParam(required = false) String moduleId, - @RequestParam(required = false) String platform) { + @RequestParam(required = false) String platform, + Authentication authentication) { + authorizationService.requireOwned(authentication, appKey); List result; if (moduleId != null && platform != null) { - RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase()); - result = bundleRepository.findByAppKeyAndModuleIdAndPlatformOrderByCreatedAtDesc(appKey, moduleId, p); + result = repository.findByAppKeyAndModuleIdAndPlatformOrderByCreatedAtDesc( + appKey, moduleId, parsePlatform(platform)); } else if (moduleId != null) { - result = bundleRepository.findByAppKeyAndModuleIdOrderByCreatedAtDesc(appKey, moduleId); + result = repository.findByAppKeyAndModuleIdOrderByCreatedAtDesc(appKey, moduleId); } else { - result = bundleRepository.findByAppKeyOrderByCreatedAtDesc(appKey); + result = repository.findByAppKeyOrderByCreatedAtDesc(appKey); } - return ResponseEntity.ok(ApiResponse.success(result)); + return ResponseEntity.ok(ApiResponse.success(result.stream().map(RnBundleView::from).toList())); } @PostMapping("/{id}/publish") - public ResponseEntity> publish( + public ResponseEntity> publish( @PathVariable String id, - @RequestBody(required = false) Map body) { - RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); - boolean publishImmediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately")); - String scheduledPublishAt = body != null && body.get("scheduledPublishAt") != null - ? body.get("scheduledPublishAt").toString() : null; - entity.setPublishMode(publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank()) - ? "NOW" : "SCHEDULED"); - if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) { + @RequestBody(required = false) Map body, + Authentication authentication) throws Exception { + RnBundleEntity entity = requireEntity(id, authentication); + requireImmutableIdentity(entity); + packageService.verifyStoredFile(entity); + boolean immediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately")); + String scheduled = body != null && body.get("scheduledPublishAt") != null + ? body.get("scheduledPublishAt").toString().trim() : ""; + entity.setPublishMode(immediately && scheduled.isBlank() ? "NOW" : "SCHEDULED"); + if (immediately && scheduled.isBlank()) { entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); entity.setScheduledPublishAt(null); } else { entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); - if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { - entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); + entity.setScheduledPublishAt(scheduled.isBlank() ? null : LocalDateTime.parse(scheduled)); + } + clearGray(entity); + RnBundleEntity saved = repository.save(entity); + operationLogService.record( + saved.getAppKey(), "RN_BUNDLE", saved.getId(), + saved.getPublishStatus() == RnBundleEntity.PublishStatus.PUBLISHED + ? "PUBLISH" : "SCHEDULE_PUBLISH", + null, + Map.of("moduleId", saved.getModuleId(), "version", saved.getVersion())); + return ResponseEntity.ok(ApiResponse.success(RnBundleView.from(saved))); + } + + @PostMapping("/{id}/unpublish") + public ResponseEntity> unpublish( + @PathVariable String id, + @RequestBody(required = false) Map body, + Authentication authentication) { + RnBundleEntity entity = requireEntity(id, authentication); + String reason = body == null || body.get("reason") == null + ? "" : body.get("reason").toString().trim(); + if (reason.isBlank()) throw new IllegalArgumentException("unpublish reason is required"); + entity.setPublishStatus(RnBundleEntity.PublishStatus.DEPRECATED); + RnBundleEntity saved = repository.save(entity); + operationLogService.record( + saved.getAppKey(), "RN_BUNDLE", saved.getId(), "UNPUBLISH", reason, + Map.of("moduleId", saved.getModuleId(), "version", saved.getVersion())); + return ResponseEntity.ok(ApiResponse.success(RnBundleView.from(saved))); + } + + @PostMapping("/{id}/gray") + public ResponseEntity> gray( + @PathVariable String id, + @RequestBody Map body, + Authentication authentication) throws Exception { + RnBundleEntity entity = requireEntity(id, authentication); + requireImmutableIdentity(entity); + if (publishConfigService.allowAnonymousUpdateCheck(entity.getAppKey())) { + throw new IllegalArgumentException("允许免登录检查更新的应用不支持灰度发布"); + } + boolean enabled = Boolean.TRUE.equals(body.get("enabled")); + entity.setGrayEnabled(enabled); + if (!enabled) { + clearGray(entity); + } else { + RnBundleEntity.GrayMode mode = parseGrayMode(body.get("grayMode")); + entity.setGrayMode(mode); + if (mode == RnBundleEntity.GrayMode.PERCENT) { + int percent = body.get("percent") instanceof Number n ? n.intValue() : 0; + if (percent < 1 || percent > 100) { + throw new IllegalArgumentException("gray percent must be between 1 and 100"); + } + entity.setGrayPercent(percent); + entity.setGrayMemberIds(null); + entity.setGrayGroupNames(null); + entity.setExtraMemberIds(null); + } else { + List groups = extractValues(body.get("groupNames")); + List extraIds = extractValues(body.get("extraMemberIds")); + entity.setGrayPercent(0); + entity.setGrayGroupNames(objectMapper.writeValueAsString(groups)); + entity.setExtraMemberIds(objectMapper.writeValueAsString(extraIds)); + entity.setGrayMemberIds(grayMemberService.resolveMemberIds( + entity.getAppKey(), groups, extraIds)); } } + entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); + RnBundleEntity saved = repository.save(entity); + operationLogService.record( + saved.getAppKey(), "RN_BUNDLE", saved.getId(), "GRAY_UPDATE", null, + Map.of("moduleId", saved.getModuleId(), "version", saved.getVersion(), + "grayMode", saved.getGrayMode().name())); + return ResponseEntity.ok(ApiResponse.success(RnBundleView.from(saved))); + } + + private RnBundleEntity requireEntity(String id, Authentication authentication) { + RnBundleEntity entity = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("RN bundle not found")); + authorizationService.requireOwned(authentication, entity.getAppKey()); + return entity; + } + + private void requireImmutableIdentity(RnBundleEntity entity) { + if (!entity.hasTerminalIdentity()) { + throw new IllegalArgumentException("legacy RN bundle must be uploaded again"); + } + String canonical = RnReleaseManifest.fromEntity(entity).canonicalJson(objectMapper); + if (!Objects.equals(canonical, entity.getSignedManifest())) { + throw new IllegalStateException("RN bundle signed identity was modified"); + } + } + + private RnBundleEntity toEntity( + String id, String appKey, String note, RnBundlePackage bundle) { + RnBundleEntity entity = new RnBundleEntity(); + entity.setId(id); + entity.setAppKey(appKey); + entity.setPackageName(bundle.packageName()); + entity.setModuleId(bundle.moduleId()); + entity.setModuleType(bundle.moduleType()); + entity.setPlatform(bundle.platform()); + entity.setVersion(bundle.version()); + entity.setAppVersionRange(bundle.appVersionRange()); + entity.setBuiltAgainstNativeBaselineId(bundle.builtAgainstNativeBaselineId()); + entity.setBuildId(bundle.buildId()); + entity.setBundleFormat(bundle.bundleFormat()); + entity.setCommonVersionRange(bundle.commonVersionRange()); + entity.setMinNativeApiLevel(bundle.minNativeApiLevel()); + entity.setBundleSha256(bundle.bundleSha256()); + entity.setArchiveSha256(bundle.archiveSha256()); + entity.setNote(note == null ? null : note.trim()); + entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); + entity.setPublishMode("MANUAL"); + clearGray(entity); + entity.setCreatedAt(LocalDateTime.now()); + return entity; + } + + private void clearGray(RnBundleEntity entity) { entity.setGrayEnabled(false); entity.setGrayPercent(0); entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); entity.setGrayGroupNames(null); entity.setExtraMemberIds(null); - RnBundleEntity saved = bundleRepository.save(entity); - operationLogService.record( - saved.getAppKey(), - "RN_BUNDLE", - saved.getId(), - publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank()) ? "PUBLISH" : "SCHEDULE_PUBLISH", - null, - Map.of( - "moduleId", saved.getModuleId(), - "version", saved.getVersion(), - "publishMode", saved.getPublishMode(), - "scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString() - )); - return ResponseEntity.ok(ApiResponse.success(saved)); } - @PostMapping("/{id}/unpublish") - public ResponseEntity> unpublish( - @PathVariable String id, - @RequestBody(required = false) Map body) { - RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); - String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : ""; - if (reason.isBlank()) { - throw new IllegalArgumentException("unpublish reason is required"); + static void requireEqual(String requestValue, String manifestValue, String field) { + if (requestValue != null && !requestValue.trim().equals(manifestValue)) { + throw new IllegalArgumentException("request " + field + " does not match rn-manifest.json"); } - entity.setPublishStatus(RnBundleEntity.PublishStatus.DEPRECATED); - RnBundleEntity saved = bundleRepository.save(entity); - operationLogService.record( - saved.getAppKey(), - "RN_BUNDLE", - saved.getId(), - "UNPUBLISH", - reason, - Map.of( - "moduleId", saved.getModuleId(), - "version", saved.getVersion() - )); - return ResponseEntity.ok(ApiResponse.success(saved)); } - @PostMapping("/{id}/gray") - public ResponseEntity> gray( - @PathVariable String id, - @RequestBody Map body) throws Exception { - RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); - if (publishConfigService.allowAnonymousUpdateCheck(entity.getAppKey())) { - throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布"); + private static void requireEqualIgnoreCase( + String requestValue, String manifestValue, String field) { + if (requestValue != null && !requestValue.trim().equalsIgnoreCase(manifestValue)) { + throw new IllegalArgumentException("request " + field + " does not match rn-manifest.json"); } - boolean enabled = Boolean.TRUE.equals(body.get("enabled")); - entity.setGrayEnabled(enabled); - if (!enabled) { - entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); - entity.setGrayPercent(0); - entity.setGrayMemberIds(null); - entity.setGrayGroupNames(null); - entity.setExtraMemberIds(null); - } else { - RnBundleEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode")); - entity.setGrayMode(grayMode); - switch (grayMode) { - case PERCENT -> { - entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); - entity.setGrayMemberIds(null); - entity.setGrayGroupNames(null); - entity.setExtraMemberIds(null); - } - case MEMBERS -> { - entity.setGrayPercent(0); - List groupNames = extractMemberIds(body.get("groupNames")); - List extraIds = extractMemberIds(body.get("extraMemberIds")); - entity.setGrayGroupNames(toJson(groupNames)); - entity.setExtraMemberIds(toJson(extraIds)); - String resolved = grayMemberService.resolveMemberIds( - entity.getAppKey(), groupNames, extraIds); - entity.setGrayMemberIds(resolved); - } - } - } - entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); - RnBundleEntity saved = bundleRepository.save(entity); - operationLogService.record( - saved.getAppKey(), - "RN_BUNDLE", - saved.getId(), - "GRAY_UPDATE", - null, - Map.of( - "moduleId", saved.getModuleId(), - "version", saved.getVersion(), - "grayMode", saved.getGrayMode().name(), - "grayPercent", saved.getGrayPercent(), - "memberCount", 0 - )); - return ResponseEntity.ok(ApiResponse.success(saved)); } - private RnBundleEntity.GrayMode parseGrayMode(Object raw) { - if (raw == null) { - return RnBundleEntity.GrayMode.PERCENT; + private static void requireEqualNullable( + String requestValue, String manifestValue, String field) { + if (requestValue == null) return; + String normalized = requestValue.trim(); + if (normalized.isEmpty()) normalized = null; + if (!Objects.equals(normalized, manifestValue)) { + throw new IllegalArgumentException("request " + field + " does not match rn-manifest.json"); } + } + + private static RnBundleEntity.Platform parsePlatform(String platform) { try { - return RnBundleEntity.GrayMode.valueOf(raw.toString().trim().toUpperCase()); - } catch (IllegalArgumentException e) { - return RnBundleEntity.GrayMode.PERCENT; - } - } - - private boolean isInGrayRelease(RnBundleEntity b, String userId) { - return switch (b.getGrayMode()) { - case PERCENT -> Math.abs(userId.hashCode()) % 100 < b.getGrayPercent(); - case MEMBERS -> { - if (b.getGrayMemberIds() == null) yield false; - try { - List ids = objectMapper.readValue(b.getGrayMemberIds(), - new com.fasterxml.jackson.core.type.TypeReference>() {}); - yield ids.contains(userId); - } catch (Exception e) { - yield false; - } - } - }; - } - - private String resolvePublicBaseUrl() { - String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - String suffix = "/api/v1/updates"; - if (normalized.endsWith(suffix)) { - return normalized.substring(0, normalized.length() - suffix.length()); - } - return normalized; - } - - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } - - private RnBundleEntity.Platform parsePlatform(String platform) { - if (!hasText(platform)) { - return null; - } - try { - return RnBundleEntity.Platform.valueOf(platform.toUpperCase()); + return RnBundleEntity.Platform.valueOf(platform.trim().toUpperCase(Locale.ROOT)); } catch (Exception e) { - return null; + throw new IllegalArgumentException("platform must be ANDROID or IOS"); } } - private int parseBundleVersion(String version) { - if (!hasText(version)) { - return 0; - } + private static RnBundleEntity.GrayMode parseGrayMode(Object raw) { try { - return Integer.parseInt(version.trim()); - } catch (Exception ignored) { - return 0; - } - } - - private String toJson(List values) { - try { - return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values); + return RnBundleEntity.GrayMode.valueOf( + raw == null ? "PERCENT" : raw.toString().trim().toUpperCase(Locale.ROOT)); } catch (Exception e) { - return "[]"; + throw new IllegalArgumentException("grayMode must be PERCENT or MEMBERS"); } } - private List extractMemberIds(Object raw) { - if (raw == null) { - return List.of(); + private List extractValues(Object raw) { + if (raw == null) return List.of(); + if (raw instanceof List values) { + return values.stream().map(String::valueOf).map(String::trim) + .filter(value -> !value.isBlank()).distinct().toList(); } - if (raw instanceof List list) { - return list.stream() - .map(String::valueOf) - .filter(this::hasText) - .map(String::trim) - .toList(); - } - if (raw instanceof String s) { - if (!hasText(s)) { - return List.of(); - } - try { - return java.util.Arrays.stream(s.split(",")) - .map(String::trim) - .filter(this::hasText) - .toList(); - } catch (Exception ignored) { - return List.of(); - } - } - return List.of(); + return Arrays.stream(raw.toString().split(",")).map(String::trim) + .filter(value -> !value.isBlank()).distinct().toList(); } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java index 0fb3572..b0c4541 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java @@ -3,11 +3,9 @@ package com.xuqm.update.controller; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.AppVersionEntity; -import com.xuqm.update.entity.RnBundleEntity; import com.xuqm.update.model.UnifiedReleaseManifest; import com.xuqm.update.model.UnifiedReleaseResult; import com.xuqm.update.repository.AppVersionRepository; -import com.xuqm.update.repository.RnBundleRepository; import com.xuqm.update.service.UpdateAssetService; import com.xuqm.update.service.UpdateOperationLogService; import jakarta.servlet.http.HttpServletRequest; @@ -31,19 +29,16 @@ public class UnifiedReleaseController { private final ObjectMapper objectMapper; private final AppVersionRepository appVersionRepository; - private final RnBundleRepository rnBundleRepository; private final UpdateAssetService updateAssetService; private final UpdateOperationLogService operationLogService; public UnifiedReleaseController( ObjectMapper objectMapper, AppVersionRepository appVersionRepository, - RnBundleRepository rnBundleRepository, UpdateAssetService updateAssetService, UpdateOperationLogService operationLogService) { this.objectMapper = objectMapper; this.appVersionRepository = appVersionRepository; - this.rnBundleRepository = rnBundleRepository; this.updateAssetService = updateAssetService; this.operationLogService = operationLogService; } @@ -108,46 +103,7 @@ public class UnifiedReleaseController { appVersions.add(saved); } - List rnBundles = new ArrayList<>(); - for (UnifiedReleaseManifest.RnBundleUploadItem item : safeList(unifiedReleaseManifest.rnBundles())) { - MultipartFile file = multipartRequest.getFile(item.fileKey()); - UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle( - appKey, - item.platform().name(), - item.moduleId(), - file); - - RnBundleEntity entity = new RnBundleEntity(); - entity.setId(UUID.randomUUID().toString()); - entity.setAppKey(appKey); - entity.setModuleId(item.moduleId()); - entity.setPlatform(item.platform()); - entity.setVersion(item.version()); - entity.setBundleUrl(stored.bundlePath()); - entity.setMd5(stored.md5()); - entity.setMinCommonVersion(item.minCommonVersion()); - entity.setPackageName(item.packageName()); - entity.setNote(item.note()); - entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); - entity.setCreatedAt(LocalDateTime.now()); - RnBundleEntity saved = rnBundleRepository.save(entity); - operationLogService.record( - saved.getAppKey(), - "RN_BUNDLE", - saved.getId(), - "UPLOAD", - null, - Map.of( - "moduleId", saved.getModuleId(), - "platform", saved.getPlatform().name(), - "version", saved.getVersion(), - "minCommonVersion", saved.getMinCommonVersion() == null ? "" : saved.getMinCommonVersion(), - "packageName", saved.getPackageName() == null ? "" : saved.getPackageName() - )); - rnBundles.add(saved); - } - - return ResponseEntity.ok(ApiResponse.success(new UnifiedReleaseResult(appVersions, rnBundles))); + return ResponseEntity.ok(ApiResponse.success(new UnifiedReleaseResult(appVersions))); } private static List safeList(List input) { diff --git a/update-service/src/main/java/com/xuqm/update/controller/UpdateFileController.java b/update-service/src/main/java/com/xuqm/update/controller/UpdateFileController.java index c02f8d7..6a1c62b 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/UpdateFileController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/UpdateFileController.java @@ -1,5 +1,8 @@ package com.xuqm.update.controller; +import com.xuqm.update.entity.RnBundleEntity; +import com.xuqm.update.repository.RnBundleRepository; +import com.xuqm.update.service.RnBundlePackageService; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -23,20 +26,38 @@ public class UpdateFileController { @Value("${update.upload-dir:/tmp/xuqm-update}") private String uploadDir; + private final RnBundleRepository rnBundleRepository; + private final RnBundlePackageService rnBundlePackageService; + + public UpdateFileController( + RnBundleRepository rnBundleRepository, + RnBundlePackageService rnBundlePackageService) { + this.rnBundleRepository = rnBundleRepository; + this.rnBundlePackageService = rnBundlePackageService; + } + @GetMapping({"/files/apk/{filename:.+}", "/api/v1/updates/files/apk/{filename:.+}"}) public ResponseEntity downloadApk(@PathVariable String filename) throws Exception { Path file = Paths.get(uploadDir, "apk", filename).normalize(); return serveFile(file, MediaType.parseMediaType("application/vnd.android.package-archive")); } - @GetMapping("/api/v1/rn/files/{appKey}/{platform}/{moduleId}") + @GetMapping("/api/v1/rn/files/{id}/{appKey}/{platform}/{moduleId}") public ResponseEntity downloadRnBundle( + @PathVariable String id, @PathVariable String appKey, @PathVariable String platform, @PathVariable String moduleId) throws Exception { - Path file = Paths.get(uploadDir, "rn", appKey, platform, moduleId, - moduleId + "." + platform + ".bundle").normalize(); - return serveFile(file, MediaType.APPLICATION_OCTET_STREAM); + RnBundleEntity entity = rnBundleRepository.findById(id).orElse(null); + if (entity == null || entity.getPublishStatus() != RnBundleEntity.PublishStatus.PUBLISHED + || !entity.hasTerminalIdentity() + || !appKey.equals(entity.getAppKey()) + || !moduleId.equals(entity.getModuleId()) + || !platform.equalsIgnoreCase(entity.getPlatform().name())) { + return ResponseEntity.notFound().build(); + } + Path file = rnBundlePackageService.verifyStoredFile(entity); + return serveFile(file, MediaType.parseMediaType("application/zip")); } private ResponseEntity serveFile(Path file, MediaType mediaType) throws Exception { diff --git a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java index 634c9fd..57bf9ea 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java @@ -8,11 +8,18 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.LocalDateTime; +/** + * 一个经过平台签名的 RN 插件发布物。 + * + *

身份字段全部来自 ZIP 内的 rn-manifest.json,发布、灰度和下线只能改变发布状态, + * 不能修改这些不可变字段。历史旧记录缺少 archiveSha256/signature,永远不会进入候选集。

+ */ @Entity @Table(name = "update_rn_bundle") public class RnBundleEntity { - public enum Platform { ANDROID, IOS, HARMONY } + public enum Platform { ANDROID, IOS } + public enum ModuleType { COMMON, APP, BUZ } public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } public enum GrayMode { PERCENT, MEMBERS } @@ -22,27 +29,61 @@ public class RnBundleEntity { @Column(nullable = false, length = 64) private String appKey; + @Column(nullable = false, length = 256) + private String packageName; + @Column(nullable = false, length = 64) private String moduleId; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ModuleType moduleType; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 16) private Platform platform; - @Column(nullable = false, length = 32) + @Column(nullable = false, length = 64) private String version; + @Column(nullable = false, length = 128) + private String appVersionRange; + + @Column(nullable = false, length = 128) + private String builtAgainstNativeBaselineId; + + @Column(nullable = false, length = 128) + private String buildId; + + @Column(nullable = false, length = 32) + private String bundleFormat; + + @Column(length = 128) + private String commonVersionRange; + + @Column(nullable = false) + private Integer minNativeApiLevel; + + /** ZIP 内真实 JS/Hermes Bundle 的 SHA-256。 */ + @Column(nullable = false, length = 64) + private String bundleSha256; + + /** 客户端实际下载并校验的整个 .xuqm.zip 的 SHA-256。 */ + @Column(nullable = false, length = 64) + private String archiveSha256; + @Column(nullable = false, length = 512) private String bundleUrl; + /** Ed25519 签名覆盖的规范 JSON,必须与上面的身份字段逐项一致。 */ + @Column(nullable = false, columnDefinition = "TEXT") + private String signedManifest; + @Column(nullable = false, length = 64) - private String md5; + private String signingKeyId; - @Column(length = 32) - private String minCommonVersion; - - @Column(length = 256) - private String packageName; + @Column(nullable = false, length = 256) + private String signature; @Column(length = 512) private String note; @@ -57,10 +98,10 @@ public class RnBundleEntity { private LocalDateTime scheduledPublishAt; @Column(nullable = false) - private boolean grayEnabled = false; + private boolean grayEnabled; @Column(nullable = false) - private int grayPercent = 0; + private int grayPercent; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 24) @@ -78,63 +119,80 @@ public class RnBundleEntity { @Column(nullable = false) private LocalDateTime createdAt; + public boolean hasTerminalIdentity() { + return hasText(packageName) && moduleType != null && hasText(appVersionRange) + && hasText(builtAgainstNativeBaselineId) && hasText(buildId) && hasText(bundleFormat) + && minNativeApiLevel != null && minNativeApiLevel > 0 && isSha256(bundleSha256) + && isSha256(archiveSha256) && hasText(signedManifest) && hasText(signingKeyId) + && hasText(signature); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static boolean isSha256(String value) { + return value != null && value.matches("(?i)[a-f0-9]{64}"); + } + public String getId() { return id; } public void setId(String id) { this.id = id; } - public String getAppKey() { return appKey; } public void setAppKey(String appKey) { this.appKey = appKey; } - - public String getModuleId() { return moduleId; } - public void setModuleId(String moduleId) { this.moduleId = moduleId; } - - public Platform getPlatform() { return platform; } - public void setPlatform(Platform platform) { this.platform = platform; } - - public String getVersion() { return version; } - public void setVersion(String version) { this.version = version; } - - public String getBundleUrl() { return bundleUrl; } - public void setBundleUrl(String bundleUrl) { this.bundleUrl = bundleUrl; } - - public String getMd5() { return md5; } - public void setMd5(String md5) { this.md5 = md5; } - - public String getMinCommonVersion() { return minCommonVersion; } - public void setMinCommonVersion(String minCommonVersion) { this.minCommonVersion = minCommonVersion; } - public String getPackageName() { return packageName; } public void setPackageName(String packageName) { this.packageName = packageName; } - + public String getModuleId() { return moduleId; } + public void setModuleId(String moduleId) { this.moduleId = moduleId; } + public ModuleType getModuleType() { return moduleType; } + public void setModuleType(ModuleType moduleType) { this.moduleType = moduleType; } + public Platform getPlatform() { return platform; } + public void setPlatform(Platform platform) { this.platform = platform; } + public String getVersion() { return version; } + public void setVersion(String version) { this.version = version; } + public String getAppVersionRange() { return appVersionRange; } + public void setAppVersionRange(String value) { this.appVersionRange = value; } + public String getBuiltAgainstNativeBaselineId() { return builtAgainstNativeBaselineId; } + public void setBuiltAgainstNativeBaselineId(String value) { this.builtAgainstNativeBaselineId = value; } + public String getBuildId() { return buildId; } + public void setBuildId(String buildId) { this.buildId = buildId; } + public String getBundleFormat() { return bundleFormat; } + public void setBundleFormat(String bundleFormat) { this.bundleFormat = bundleFormat; } + public String getCommonVersionRange() { return commonVersionRange; } + public void setCommonVersionRange(String value) { this.commonVersionRange = value; } + public Integer getMinNativeApiLevel() { return minNativeApiLevel; } + public void setMinNativeApiLevel(Integer value) { this.minNativeApiLevel = value; } + public String getBundleSha256() { return bundleSha256; } + public void setBundleSha256(String value) { this.bundleSha256 = value; } + public String getArchiveSha256() { return archiveSha256; } + public void setArchiveSha256(String value) { this.archiveSha256 = value; } + public String getBundleUrl() { return bundleUrl; } + public void setBundleUrl(String bundleUrl) { this.bundleUrl = bundleUrl; } + public String getSignedManifest() { return signedManifest; } + public void setSignedManifest(String value) { this.signedManifest = value; } + public String getSigningKeyId() { return signingKeyId; } + public void setSigningKeyId(String value) { this.signingKeyId = value; } + public String getSignature() { return signature; } + public void setSignature(String value) { this.signature = value; } public String getNote() { return note; } public void setNote(String note) { this.note = note; } - public PublishStatus getPublishStatus() { return publishStatus; } - public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; } - + public void setPublishStatus(PublishStatus value) { this.publishStatus = value; } public String getPublishMode() { return publishMode; } - public void setPublishMode(String publishMode) { this.publishMode = publishMode; } - + public void setPublishMode(String value) { this.publishMode = value; } public LocalDateTime getScheduledPublishAt() { return scheduledPublishAt; } - public void setScheduledPublishAt(LocalDateTime scheduledPublishAt) { this.scheduledPublishAt = scheduledPublishAt; } - + public void setScheduledPublishAt(LocalDateTime value) { this.scheduledPublishAt = value; } public boolean isGrayEnabled() { return grayEnabled; } - public void setGrayEnabled(boolean grayEnabled) { this.grayEnabled = grayEnabled; } - + public void setGrayEnabled(boolean value) { this.grayEnabled = value; } public int getGrayPercent() { return grayPercent; } - public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; } - + public void setGrayPercent(int value) { this.grayPercent = value; } public GrayMode getGrayMode() { return grayMode; } - public void setGrayMode(GrayMode grayMode) { this.grayMode = grayMode; } - + public void setGrayMode(GrayMode value) { this.grayMode = value; } public String getGrayMemberIds() { return grayMemberIds; } - public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } - + public void setGrayMemberIds(String value) { this.grayMemberIds = value; } public String getGrayGroupNames() { return grayGroupNames; } - public void setGrayGroupNames(String grayGroupNames) { this.grayGroupNames = grayGroupNames; } - + public void setGrayGroupNames(String value) { this.grayGroupNames = value; } public String getExtraMemberIds() { return extraMemberIds; } - public void setExtraMemberIds(String extraMemberIds) { this.extraMemberIds = extraMemberIds; } - + public void setExtraMemberIds(String value) { this.extraMemberIds = value; } public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public void setCreatedAt(LocalDateTime value) { this.createdAt = value; } } diff --git a/update-service/src/main/java/com/xuqm/update/model/RnBundleInspectResult.java b/update-service/src/main/java/com/xuqm/update/model/RnBundleInspectResult.java deleted file mode 100644 index a27efa5..0000000 --- a/update-service/src/main/java/com/xuqm/update/model/RnBundleInspectResult.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.xuqm.update.model; - -public record RnBundleInspectResult( - String moduleId, - String platform, - String version, - String minCommonVersion, - String packageName, - String fileName, - boolean detected) { -} diff --git a/update-service/src/main/java/com/xuqm/update/model/RnBundlePackage.java b/update-service/src/main/java/com/xuqm/update/model/RnBundlePackage.java new file mode 100644 index 0000000..8e31960 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/RnBundlePackage.java @@ -0,0 +1,22 @@ +package com.xuqm.update.model; + +import com.xuqm.update.entity.RnBundleEntity; +import java.nio.file.Path; + +/** 经过严格校验但尚未持久化的 RN ZIP 发布物。 */ +public record RnBundlePackage( + Path temporaryFile, + String archiveSha256, + String packageName, + String moduleId, + RnBundleEntity.ModuleType moduleType, + RnBundleEntity.Platform platform, + String version, + String appVersionRange, + String builtAgainstNativeBaselineId, + String buildId, + String bundleFormat, + String commonVersionRange, + int minNativeApiLevel, + String bundleSha256) { +} diff --git a/update-service/src/main/java/com/xuqm/update/model/RnBundleView.java b/update-service/src/main/java/com/xuqm/update/model/RnBundleView.java new file mode 100644 index 0000000..5590a81 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/RnBundleView.java @@ -0,0 +1,62 @@ +package com.xuqm.update.model; + +import com.xuqm.update.entity.RnBundleEntity; +import java.time.LocalDateTime; + +/** 管理端可见的发布记录;不暴露服务端绝对文件路径。 */ +public record RnBundleView( + String id, + String appKey, + String packageName, + String moduleId, + String type, + String platform, + String version, + String appVersionRange, + String builtAgainstNativeBaselineId, + String buildId, + String bundleFormat, + String commonVersionRange, + Integer minNativeApiLevel, + String bundleSha256, + String archiveSha256, + String keyId, + String signature, + String note, + String publishStatus, + String publishMode, + LocalDateTime scheduledPublishAt, + boolean grayEnabled, + int grayPercent, + String grayMode, + LocalDateTime createdAt) { + + public static RnBundleView from(RnBundleEntity entity) { + return new RnBundleView( + entity.getId(), + entity.getAppKey(), + entity.getPackageName(), + entity.getModuleId(), + entity.getModuleType() == null ? null : entity.getModuleType().name().toLowerCase(), + entity.getPlatform() == null ? null : entity.getPlatform().name(), + entity.getVersion(), + entity.getAppVersionRange(), + entity.getBuiltAgainstNativeBaselineId(), + entity.getBuildId(), + entity.getBundleFormat(), + entity.getCommonVersionRange(), + entity.getMinNativeApiLevel(), + entity.getBundleSha256(), + entity.getArchiveSha256(), + entity.getSigningKeyId(), + entity.getSignature(), + entity.getNote(), + entity.getPublishStatus() == null ? null : entity.getPublishStatus().name(), + entity.getPublishMode(), + entity.getScheduledPublishAt(), + entity.isGrayEnabled(), + entity.getGrayPercent(), + entity.getGrayMode() == null ? null : entity.getGrayMode().name(), + entity.getCreatedAt()); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/model/RnReleaseManifest.java b/update-service/src/main/java/com/xuqm/update/model/RnReleaseManifest.java new file mode 100644 index 0000000..8e3d576 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/RnReleaseManifest.java @@ -0,0 +1,71 @@ +package com.xuqm.update.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.RnBundleEntity; +import java.util.Map; +import java.util.TreeMap; + +/** + * 平台签名覆盖的 RN 发布身份。 + * + *

跨端 canonical 规则与 Config V2 相同:UTF-8、对象 key 字典序、无多余空白, + * null 字段省略。客户端必须验证签名正文与响应展开字段逐项一致。

+ */ +public record RnReleaseManifest( + String appKey, + String archiveSha256, + String packageName, + String platform, + String moduleId, + String type, + String version, + String appVersionRange, + String builtAgainstNativeBaselineId, + String buildId, + String bundleFormat, + String commonVersionRange, + int minNativeApiLevel, + String bundleSha256) { + + public String canonicalJson(ObjectMapper mapper) { + Map values = new TreeMap<>(); + values.put("appKey", appKey); + values.put("archiveSha256", archiveSha256); + values.put("packageName", packageName); + values.put("platform", platform); + values.put("moduleId", moduleId); + values.put("type", type); + values.put("version", version); + values.put("appVersionRange", appVersionRange); + values.put("builtAgainstNativeBaselineId", builtAgainstNativeBaselineId); + values.put("buildId", buildId); + values.put("bundleFormat", bundleFormat); + if (commonVersionRange != null) values.put("commonVersionRange", commonVersionRange); + values.put("minNativeApiLevel", minNativeApiLevel); + values.put("bundleSha256", bundleSha256); + try { + return mapper.writeValueAsString(values); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to canonicalize RN release manifest", e); + } + } + + public static RnReleaseManifest fromEntity(RnBundleEntity entity) { + return new RnReleaseManifest( + entity.getAppKey(), + entity.getArchiveSha256(), + entity.getPackageName(), + entity.getPlatform().name(), + entity.getModuleId(), + entity.getModuleType().name().toLowerCase(), + entity.getVersion(), + entity.getAppVersionRange(), + entity.getBuiltAgainstNativeBaselineId(), + entity.getBuildId(), + entity.getBundleFormat(), + entity.getCommonVersionRange(), + entity.getMinNativeApiLevel(), + entity.getBundleSha256()); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/model/RnReleaseSetRequest.java b/update-service/src/main/java/com/xuqm/update/model/RnReleaseSetRequest.java new file mode 100644 index 0000000..68e3d00 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/RnReleaseSetRequest.java @@ -0,0 +1,25 @@ +package com.xuqm.update.model; + +import java.util.List; + +/** RNSDK 单次检查一个 app/buz 入口及其 common 依赖闭包。 */ +public record RnReleaseSetRequest( + String appKey, + String targetModuleId, + String platform, + String userId, + String appVersion, + Integer nativeApiLevel, + String nativeBaselineId, + List installedModules) { + + public record InstalledModule( + String moduleId, + String type, + String version, + String appVersionRange, + String builtAgainstNativeBaselineId, + String commonVersionRange, + Integer minNativeApiLevel) { + } +} diff --git a/update-service/src/main/java/com/xuqm/update/model/RnReleaseSetResponse.java b/update-service/src/main/java/com/xuqm/update/model/RnReleaseSetResponse.java new file mode 100644 index 0000000..4639f1e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/RnReleaseSetResponse.java @@ -0,0 +1,33 @@ +package com.xuqm.update.model; + +import java.util.List; + +public record RnReleaseSetResponse(String releaseId, List modules) { + + public static RnReleaseSetResponse empty() { + return new RnReleaseSetResponse(null, List.of()); + } + + /** 签名字段与 signedManifest 同时返回,客户端须校验两者逐项一致。 */ + public record Module( + String appKey, + String moduleId, + String type, + String version, + String downloadUrl, + String sha256, + String packageName, + String platform, + String appVersionRange, + String builtAgainstNativeBaselineId, + String buildId, + String bundleFormat, + String commonVersionRange, + Integer minNativeApiLevel, + String bundleSha256, + String keyId, + String signature, + String signedManifest, + String note) { + } +} diff --git a/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseManifest.java b/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseManifest.java index 637db9a..ac4d7c8 100644 --- a/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseManifest.java +++ b/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseManifest.java @@ -1,13 +1,11 @@ package com.xuqm.update.model; import com.xuqm.update.entity.AppVersionEntity; -import com.xuqm.update.entity.RnBundleEntity; import java.util.List; public record UnifiedReleaseManifest( - List appVersions, - List rnBundles) { + List appVersions) { public record AppUploadItem( String fileKey, @@ -22,13 +20,4 @@ public record UnifiedReleaseManifest( boolean publishImmediately) { } - public record RnBundleUploadItem( - String fileKey, - String moduleId, - RnBundleEntity.Platform platform, - String version, - String minCommonVersion, - String packageName, - String note) { - } } diff --git a/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseResult.java b/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseResult.java index 814df94..a2815f3 100644 --- a/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseResult.java +++ b/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseResult.java @@ -1,11 +1,9 @@ package com.xuqm.update.model; import com.xuqm.update.entity.AppVersionEntity; -import com.xuqm.update.entity.RnBundleEntity; import java.util.List; public record UnifiedReleaseResult( - List appVersions, - List rnBundles) { + List appVersions) { } diff --git a/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java b/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java index fe8eeb5..b6756c5 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java @@ -4,7 +4,6 @@ import com.xuqm.update.entity.RnBundleEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Optional; import java.time.LocalDateTime; public interface RnBundleRepository extends JpaRepository { @@ -12,8 +11,8 @@ public interface RnBundleRepository extends JpaRepository findByAppKeyAndModuleIdOrderByCreatedAtDesc(String appKey, String moduleId); List findByAppKeyOrderByCreatedAtDesc(String appKey); - Optional findTopByAppKeyAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc( - String appKey, String moduleId, RnBundleEntity.Platform platform, RnBundleEntity.PublishStatus status); + List findByAppKeyAndPlatformAndPublishStatusOrderByCreatedAtDesc( + String appKey, RnBundleEntity.Platform platform, RnBundleEntity.PublishStatus status); List findByPublishStatusAndScheduledPublishAtBefore( RnBundleEntity.PublishStatus status, LocalDateTime before); diff --git a/update-service/src/main/java/com/xuqm/update/service/InternalServiceToken.java b/update-service/src/main/java/com/xuqm/update/service/InternalServiceToken.java new file mode 100644 index 0000000..cc11106 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/InternalServiceToken.java @@ -0,0 +1,14 @@ +package com.xuqm.update.service; + +/** 新内部 RN 发布链路禁止缺失令牌或继续使用历史公开占位值。 */ +final class InternalServiceToken { + + private static final String LEGACY_PLACEHOLDER = "xuqm-internal-token"; + + private InternalServiceToken() { + } + + static boolean isUsable(String token) { + return token != null && !token.isBlank() && !LEGACY_PLACEHOLDER.equals(token); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/RnBundleAuthorizationService.java b/update-service/src/main/java/com/xuqm/update/service/RnBundleAuthorizationService.java new file mode 100644 index 0000000..2e11780 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/RnBundleAuthorizationService.java @@ -0,0 +1,37 @@ +package com.xuqm.update.service; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +/** RN 管理写操作的单一租户归属门禁。 */ +@Service +public class RnBundleAuthorizationService { + + private final TenantAppOwnershipClient ownershipClient; + + public RnBundleAuthorizationService(TenantAppOwnershipClient ownershipClient) { + this.ownershipClient = ownershipClient; + } + + public void requireOwned(Authentication authentication, String appKey) { + if (authentication == null || !authentication.isAuthenticated() || appKey == null) { + throw new AccessDeniedException("RN bundle app ownership is required"); + } + if (hasRole(authentication, "ROLE_OPS")) return; + String principal = authentication.getName(); + if (hasRole(authentication, "ROLE_API_KEY")) { + if (("apikey:" + appKey).equals(principal)) return; + throw new AccessDeniedException("API Token does not own this app"); + } + if (hasRole(authentication, "ROLE_TENANT") && ownershipClient.owns(principal, appKey)) { + return; + } + throw new AccessDeniedException("Tenant does not own this app"); + } + + private boolean hasRole(Authentication authentication, String role) { + return authentication.getAuthorities().stream() + .anyMatch(authority -> role.equals(authority.getAuthority())); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/RnBundlePackageService.java b/update-service/src/main/java/com/xuqm/update/service/RnBundlePackageService.java new file mode 100644 index 0000000..7f70fd0 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/RnBundlePackageService.java @@ -0,0 +1,463 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.RnBundleEntity; +import com.xuqm.update.model.RnBundlePackage; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.DigestInputStream; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.HashMap; +import java.util.Map; +import java.util.Locale; +import java.util.Set; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipFile; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +/** + * RN 插件 ZIP 的唯一解析、校验和存储实现。 + * + *

文件名、表单默认值和历史 manifest.json 均不参与身份推导。只有 ZIP 根目录下唯一的 + * rn-manifest.json 是身份源,Bundle 内容与 manifest SHA-256 不一致时拒绝上传。

+ */ +@Service +public class RnBundlePackageService { + + private static final String MANIFEST_NAME = "rn-manifest.json"; + private static final long MAX_MANIFEST_BYTES = 256 * 1024; + /** 与 RN CLI 和 Android stager 共享的协议上限,避免服务端签出客户端无法安全解压的包。 */ + static final long MAX_ARCHIVE_BYTES = 50L * 1024 * 1024; + static final long MAX_BUNDLE_BYTES = 100L * 1024 * 1024; + static final long MAX_ENTRY_BYTES = 100L * 1024 * 1024; + static final long MAX_TOTAL_UNCOMPRESSED_BYTES = 100L * 1024 * 1024; + static final int MAX_ENTRY_COUNT = 4096; + private static final Set REQUIRED_MANIFEST_FIELDS = Set.of( + "schemaVersion", "packageName", "moduleId", "type", "platform", "version", + "appVersion", "appVersionRange", "builtAgainstNativeBaselineId", "buildId", + "bundleFormat", "bugCollectMode", "minNativeApiLevel", "bundleFile", + "bundleByteLength", "bundleSha256", "assets"); + private static final Set OPTIONAL_MANIFEST_FIELDS = Set.of("commonVersionRange"); + private static final Set ASSET_FIELDS = Set.of("path", "byteLength", "sha256"); + private final ObjectMapper objectMapper; + private final Path uploadRoot; + + public RnBundlePackageService( + ObjectMapper objectMapper, + @Value("${update.upload-dir:/tmp/xuqm-update}") String uploadDir) { + this.objectMapper = objectMapper; + this.uploadRoot = Path.of(uploadDir).toAbsolutePath().normalize(); + } + + public RnBundlePackage inspect(MultipartFile source) throws Exception { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException("bundle ZIP is required"); + } + if (source.getSize() < 1 || source.getSize() > MAX_ARCHIVE_BYTES) { + throw new IllegalArgumentException("bundle ZIP size is invalid"); + } + Path temporary = Files.createTempFile("xuqm-rn-upload-", ".xuqm.zip"); + boolean success = false; + try { + MessageDigest packageDigest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = source.getInputStream(); + DigestInputStream digestInput = new DigestInputStream(input, packageDigest)) { + Files.copy(digestInput, temporary, StandardCopyOption.REPLACE_EXISTING); + } + String archiveSha256 = HexFormat.of().formatHex(packageDigest.digest()); + RnBundlePackage result = inspectZip(temporary, archiveSha256); + success = true; + return result; + } finally { + if (!success) Files.deleteIfExists(temporary); + } + } + + public String persist(String appKey, String bundleId, RnBundlePackage bundle) throws Exception { + requireSegment(appKey, "appKey"); + requireSegment(bundleId, "bundleId"); + requireSegment(bundle.moduleId(), "moduleId"); + Path directory = uploadRoot.resolve("rn") + .resolve(appKey) + .resolve(bundle.platform().name().toLowerCase(Locale.ROOT)) + .resolve(bundle.moduleId()) + .normalize(); + requireInsideUploadRoot(directory); + Files.createDirectories(directory); + Path destination = directory.resolve(bundleId + ".xuqm.zip").normalize(); + requireInsideUploadRoot(destination); + Files.move(bundle.temporaryFile(), destination, StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + return destination.toString(); + } + + public Path resolveStoredFile(RnBundleEntity entity) { + if (entity == null || !entity.hasTerminalIdentity()) { + throw new IllegalArgumentException("RN bundle record has no terminal identity"); + } + Path path = Path.of(entity.getBundleUrl()).toAbsolutePath().normalize(); + requireInsideUploadRoot(path); + return path; + } + + public Path verifyStoredFile(RnBundleEntity entity) throws Exception { + Path path = resolveStoredFile(entity); + if (!Files.isRegularFile(path)) { + throw new IllegalArgumentException("RN bundle file does not exist"); + } + try (InputStream input = Files.newInputStream(path)) { + if (!sha256(input).equals(entity.getArchiveSha256())) { + throw new IllegalStateException("stored RN package SHA-256 mismatch"); + } + } + return path; + } + + public void deleteStoredFile(String bundleUrl) { + if (bundleUrl == null || bundleUrl.isBlank()) return; + try { + Path path = Path.of(bundleUrl).toAbsolutePath().normalize(); + requireInsideUploadRoot(path); + Files.deleteIfExists(path); + } catch (Exception ignored) { + // 数据库保存异常优先返回,孤儿文件由运维审计任务再次清理。 + } + } + + public void deleteTemporary(RnBundlePackage bundle) { + if (bundle == null || bundle.temporaryFile() == null) return; + try { + Files.deleteIfExists(bundle.temporaryFile()); + } catch (Exception ignored) { + // 上传失败后的临时文件清理由系统临时目录最终兜底,不覆盖原始业务异常。 + } + } + + private RnBundlePackage inspectZip(Path file, String archiveSha256) throws Exception { + try (ZipFile zip = ZipFile.builder().setPath(file).get()) { + Set entryNames = new HashSet<>(); + ZipArchiveEntry manifestEntry = null; + long totalUncompressed = 0; + int entryCount = 0; + Enumeration entries = zip.getEntries(); + while (entries.hasMoreElements()) { + ZipArchiveEntry entry = entries.nextElement(); + if (++entryCount > MAX_ENTRY_COUNT) { + throw new IllegalArgumentException("ZIP contains too many entries"); + } + validateEntryName(entry.getName()); + if (entry.isUnixSymlink()) { + throw new IllegalArgumentException("ZIP symbolic links are not allowed"); + } + if (entry.isDirectory()) { + throw new IllegalArgumentException("ZIP directory entries are not allowed"); + } + long size = entry.getSize(); + if (size < 0 || size > MAX_ENTRY_BYTES) { + throw new IllegalArgumentException("ZIP entry size is invalid"); + } + totalUncompressed = Math.addExact(totalUncompressed, size); + if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED_BYTES) { + throw new IllegalArgumentException("ZIP uncompressed size is too large"); + } + if (!entryNames.add(entry.getName())) { + throw new IllegalArgumentException("duplicate ZIP entry: " + entry.getName()); + } + if (MANIFEST_NAME.equals(entry.getName())) manifestEntry = entry; + } + if (manifestEntry == null || manifestEntry.isDirectory()) { + throw new IllegalArgumentException("ZIP root rn-manifest.json is required"); + } + if (manifestEntry.getSize() < 0 || manifestEntry.getSize() > MAX_MANIFEST_BYTES) { + throw new IllegalArgumentException("rn-manifest.json size is invalid"); + } + JsonNode manifest; + try (InputStream input = zip.getInputStream(manifestEntry)) { + JsonParser parser = objectMapper.getFactory().createParser(input); + parser.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION.mappedFeature()); + manifest = objectMapper.readTree(parser); + } + if (manifest == null || !manifest.isObject()) { + throw new IllegalArgumentException("rn-manifest.json must be a JSON object"); + } + validateManifestFields(manifest); + + int schemaVersion = requiredPositiveInt(manifest, "schemaVersion"); + if (schemaVersion != 1) { + throw new IllegalArgumentException("unsupported rn-manifest schemaVersion: " + schemaVersion); + } + String moduleId = requiredText(manifest, "moduleId"); + if (!moduleId.matches("[a-z][a-z0-9-]*")) { + throw new IllegalArgumentException("manifest moduleId is invalid"); + } + RnBundleEntity.ModuleType type = parseType(requiredText(manifest, "type")); + RnBundleEntity.Platform platform = parsePlatform(requiredText(manifest, "platform")); + String version = requiredText(manifest, "version"); + RnVersionContract.parse(version); + String appVersion = requiredText(manifest, "appVersion"); + RnVersionContract.parse(appVersion); + String appVersionRange = requiredText(manifest, "appVersionRange"); + RnVersionContract.validateRange(appVersionRange); + if (!RnVersionContract.satisfies(appVersion, appVersionRange)) { + throw new IllegalArgumentException("manifest appVersion is outside appVersionRange"); + } + String nativeBaseline = requiredText(manifest, "builtAgainstNativeBaselineId"); + String buildId = requiredText(manifest, "buildId"); + if ("development".equalsIgnoreCase(buildId)) { + throw new IllegalArgumentException("release buildId must not be development"); + } + String bundleFormat = requiredText(manifest, "bundleFormat"); + if (!Set.of("javascript", "hermes-bytecode").contains(bundleFormat)) { + throw new IllegalArgumentException("manifest bundleFormat is unsupported"); + } + String bugCollectMode = requiredText(manifest, "bugCollectMode"); + if (!Set.of("platform", "disabled").contains(bugCollectMode)) { + throw new IllegalArgumentException("manifest bugCollectMode is unsupported"); + } + String commonRange = optionalText(manifest, "commonVersionRange"); + if (type == RnBundleEntity.ModuleType.COMMON) { + if (commonRange != null) { + throw new IllegalArgumentException("common module must not declare commonVersionRange"); + } + } else { + if (commonRange == null) { + throw new IllegalArgumentException("app/buz module requires commonVersionRange"); + } + RnVersionContract.validateRange(commonRange); + } + int minNativeApiLevel = requiredPositiveInt(manifest, "minNativeApiLevel"); + String packageName = requiredText(manifest, "packageName"); + String bundleFile = requiredText(manifest, "bundleFile"); + String expectedBundleName = moduleId + "." + platform.name().toLowerCase(Locale.ROOT) + ".bundle"; + if (!expectedBundleName.equals(bundleFile)) { + throw new IllegalArgumentException("manifest bundleFile does not match module identity"); + } + String expectedBundleSha = requiredSha256(manifest, "bundleSha256"); + long declaredBundleBytes = requiredNonNegativeLong(manifest, "bundleByteLength"); + if (declaredBundleBytes > MAX_BUNDLE_BYTES) { + throw new IllegalArgumentException("manifest bundleByteLength is too large"); + } + ZipArchiveEntry bundleEntry = zip.getEntry(bundleFile); + if (bundleEntry == null || bundleEntry.isDirectory()) { + throw new IllegalArgumentException("manifest bundleFile is missing from ZIP"); + } + if (bundleEntry.getSize() < 0 || bundleEntry.getSize() > MAX_BUNDLE_BYTES) { + throw new IllegalArgumentException("bundle file size is invalid"); + } + DigestResult bundleDigest; + try (InputStream input = zip.getInputStream(bundleEntry)) { + bundleDigest = sha256(input, MAX_BUNDLE_BYTES); + } + if (bundleDigest.byteLength() != declaredBundleBytes + || bundleDigest.byteLength() != bundleEntry.getSize()) { + throw new IllegalArgumentException("manifest bundleByteLength does not match ZIP content"); + } + if (!bundleDigest.sha256().equals(expectedBundleSha)) { + throw new IllegalArgumentException("manifest bundleSha256 does not match ZIP content"); + } + validateAssets(zip, manifest.get("assets"), entryNames, bundleFile, bundleDigest.byteLength()); + return new RnBundlePackage( + file, + archiveSha256, + packageName, + moduleId, + type, + platform, + version, + appVersionRange, + nativeBaseline, + buildId, + bundleFormat, + commonRange, + minNativeApiLevel, + bundleDigest.sha256()); + } + } + + private static void validateEntryName(String name) { + if (name == null || name.isBlank() || name.startsWith("/") || name.contains("\\") + || name.matches("^[A-Za-z]:.*")) { + throw new IllegalArgumentException("unsafe ZIP entry name"); + } + for (String segment : name.split("/", -1)) { + if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) { + throw new IllegalArgumentException("unsafe ZIP entry name"); + } + } + } + + private void validateManifestFields(JsonNode manifest) { + Set actual = new HashSet<>(); + manifest.fieldNames().forEachRemaining(actual::add); + Set allowed = new HashSet<>(REQUIRED_MANIFEST_FIELDS); + allowed.addAll(OPTIONAL_MANIFEST_FIELDS); + if (!actual.containsAll(REQUIRED_MANIFEST_FIELDS) || !allowed.containsAll(actual)) { + throw new IllegalArgumentException("rn-manifest.json fields are invalid"); + } + } + + private void validateAssets( + ZipFile zip, + JsonNode assetsNode, + Set entryNames, + String bundleFile, + long bundleLength) throws Exception { + if (assetsNode == null || !assetsNode.isArray()) { + throw new IllegalArgumentException("manifest assets must be an array"); + } + Map declared = new HashMap<>(); + long declaredTotal = bundleLength; + for (JsonNode asset : assetsNode) { + if (!asset.isObject()) { + throw new IllegalArgumentException("manifest asset must be an object"); + } + Set fields = new HashSet<>(); + asset.fieldNames().forEachRemaining(fields::add); + if (!fields.equals(ASSET_FIELDS)) { + throw new IllegalArgumentException("manifest asset fields are invalid"); + } + String relativePath = requiredText(asset, "path"); + validateEntryName(relativePath); + String archivePath = "assets/" + relativePath; + validateEntryName(archivePath); + long byteLength = requiredNonNegativeLong(asset, "byteLength"); + if (byteLength > MAX_ENTRY_BYTES) { + throw new IllegalArgumentException("manifest asset is too large"); + } + String sha = requiredSha256(asset, "sha256"); + if (declared.putIfAbsent(archivePath, new AssetIdentity(byteLength, sha)) != null) { + throw new IllegalArgumentException("duplicate manifest asset path"); + } + declaredTotal = Math.addExact(declaredTotal, byteLength); + if (declaredTotal > MAX_TOTAL_UNCOMPRESSED_BYTES) { + throw new IllegalArgumentException("manifest assets are too large"); + } + } + Set expectedEntries = new HashSet<>(declared.keySet()); + expectedEntries.add(MANIFEST_NAME); + expectedEntries.add(bundleFile); + if (!entryNames.equals(expectedEntries)) { + throw new IllegalArgumentException("ZIP contains undeclared or missing assets"); + } + for (Map.Entry asset : declared.entrySet()) { + ZipArchiveEntry entry = zip.getEntry(asset.getKey()); + if (entry == null || entry.isDirectory() || entry.isUnixSymlink()) { + throw new IllegalArgumentException("manifest asset is missing from ZIP"); + } + DigestResult actual; + try (InputStream input = zip.getInputStream(entry)) { + actual = sha256(input, MAX_ENTRY_BYTES); + } + if (actual.byteLength() != asset.getValue().byteLength() + || actual.byteLength() != entry.getSize() + || !actual.sha256().equals(asset.getValue().sha256())) { + throw new IllegalArgumentException("manifest asset does not match ZIP content"); + } + } + } + + private void requireInsideUploadRoot(Path path) { + if (!path.startsWith(uploadRoot)) { + throw new IllegalArgumentException("RN bundle path escapes the upload directory"); + } + } + + private static void requireSegment(String value, String field) { + if (value == null || !value.matches("[A-Za-z0-9._-]+")) { + throw new IllegalArgumentException(field + " is invalid"); + } + } + + private static RnBundleEntity.ModuleType parseType(String value) { + try { + return RnBundleEntity.ModuleType.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (Exception e) { + throw new IllegalArgumentException("manifest type must be common, app or buz"); + } + } + + private static RnBundleEntity.Platform parsePlatform(String value) { + try { + return RnBundleEntity.Platform.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (Exception e) { + throw new IllegalArgumentException("manifest platform must be android or ios"); + } + } + + private static String requiredText(JsonNode node, String field) { + String value = optionalText(node, field); + if (value == null) throw new IllegalArgumentException("manifest " + field + " is required"); + return value; + } + + private static String optionalText(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) return null; + if (!value.isTextual() || value.textValue().isBlank()) { + throw new IllegalArgumentException("manifest " + field + " must be a non-empty string"); + } + return value.textValue().trim(); + } + + private static int requiredPositiveInt(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || !value.isIntegralNumber() || !value.canConvertToInt() + || value.intValue() < 1) { + throw new IllegalArgumentException("manifest " + field + " must be a positive integer"); + } + return value.intValue(); + } + + private static long requiredNonNegativeLong(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || !value.isIntegralNumber() || !value.canConvertToLong() + || value.longValue() < 0) { + throw new IllegalArgumentException("manifest " + field + " must be a non-negative integer"); + } + return value.longValue(); + } + + private static String requiredSha256(JsonNode node, String field) { + String value = requiredText(node, field).toLowerCase(Locale.ROOT); + if (!value.matches("[a-f0-9]{64}")) { + throw new IllegalArgumentException("manifest " + field + " must be SHA-256"); + } + return value; + } + + private static String sha256(InputStream input) throws Exception { + return sha256(input, Long.MAX_VALUE).sha256(); + } + + private static DigestResult sha256(InputStream input, long limit) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + long byteLength = 0; + try (DigestInputStream digestInput = new DigestInputStream(input, digest)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = digestInput.read(buffer)) >= 0) { + byteLength = Math.addExact(byteLength, read); + if (byteLength > limit) { + throw new IllegalArgumentException("ZIP entry expands beyond its allowed size"); + } + } + } + return new DigestResult(HexFormat.of().formatHex(digest.digest()), byteLength); + } + + private record AssetIdentity(long byteLength, String sha256) { + } + + private record DigestResult(String sha256, long byteLength) { + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/RnReleaseSetService.java b/update-service/src/main/java/com/xuqm/update/service/RnReleaseSetService.java new file mode 100644 index 0000000..15193a9 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/RnReleaseSetService.java @@ -0,0 +1,337 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.RnBundleEntity; +import com.xuqm.update.model.RnReleaseManifest; +import com.xuqm.update.model.RnReleaseSetRequest; +import com.xuqm.update.model.RnReleaseSetResponse; +import com.xuqm.update.repository.RnBundleRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * 服务端 RN 发布集合规划器。 + * + *

只返回目标 app/buz 和唯一 common,仍会用请求中的全部已安装模块验证 common + * 升级后的最终集合,防止一个业务插件更新破坏其他已安装插件。

+ */ +@Service +public class RnReleaseSetService { + + private final RnBundleRepository repository; + private final PublishConfigService publishConfigService; + private final ObjectMapper objectMapper; + private final String baseUrl; + + public RnReleaseSetService( + RnBundleRepository repository, + PublishConfigService publishConfigService, + ObjectMapper objectMapper, + @Value("${update.base-url:https://update.dev.xuqinmin.com}") String baseUrl) { + this.repository = repository; + this.publishConfigService = publishConfigService; + this.objectMapper = objectMapper; + this.baseUrl = baseUrl; + } + + public RnReleaseSetResponse check(RnReleaseSetRequest request) { + ValidatedRequest validated = validateRequest(request); + if (!publishConfigService.allowAnonymousUpdateCheck(validated.appKey()) + && validated.userId() == null) { + return RnReleaseSetResponse.empty(); + } + + List published = repository + .findByAppKeyAndPlatformAndPublishStatusOrderByCreatedAtDesc( + validated.appKey(), validated.platform(), RnBundleEntity.PublishStatus.PUBLISHED) + .stream() + .filter(RnBundleEntity::hasTerminalIdentity) + .filter(bundle -> eligibleForUser(bundle, validated.userId())) + .toList(); + + RnReleaseSetRequest.InstalledModule installedTarget = + validated.installed().get(validated.targetModuleId()); + List targetCandidates = published.stream() + .filter(bundle -> validated.targetModuleId().equals(bundle.getModuleId())) + .filter(bundle -> parseType(installedTarget.type()) == bundle.getModuleType()) + .filter(bundle -> RnVersionContract.parse(bundle.getVersion()) + .compareTo(RnVersionContract.parse(installedTarget.version())) > 0) + .filter(bundle -> runtimeCompatible(bundle, validated)) + .sorted(newestFirst()) + .toList(); + RnReleaseSetRequest.InstalledModule installedCommon = validated.common(); + List commonCandidates = published.stream() + .filter(bundle -> bundle.getModuleType() == RnBundleEntity.ModuleType.COMMON) + .filter(bundle -> installedCommon.moduleId().equals(bundle.getModuleId())) + .filter(bundle -> RnVersionContract.parse(bundle.getVersion()) + .compareTo(RnVersionContract.parse(installedCommon.version())) > 0) + .filter(bundle -> runtimeCompatible(bundle, validated)) + .sorted(newestFirst()) + .toList(); + + // 优先选择目标模块的最高兼容升级;每个目标依次尝试最高 common,再回退到已安装 + // common。找不到完整兼容集合时继续尝试较低目标版本,绝不返回半个依赖闭包。 + RnBundleEntity targetUpgrade = null; + RnBundleEntity commonUpgrade = null; + boolean found = false; + List targetOptions = new ArrayList<>(targetCandidates); + targetOptions.add(null); + List commonOptions = new ArrayList<>(commonCandidates); + commonOptions.add(null); + for (RnBundleEntity targetOption : targetOptions) { + for (RnBundleEntity commonOption : commonOptions) { + if (targetOption == null && commonOption == null) continue; + if (finalSetCompatible(validated, targetOption, commonOption)) { + targetUpgrade = targetOption; + commonUpgrade = commonOption; + found = true; + break; + } + } + if (found) { + break; + } + } + + List selected = new ArrayList<>(2); + if (commonUpgrade != null) selected.add(commonUpgrade); + if (targetUpgrade != null) selected.add(targetUpgrade); + if (selected.isEmpty()) return RnReleaseSetResponse.empty(); + + String releaseId = stableReleaseId(validated, selected); + return new RnReleaseSetResponse( + releaseId, + selected.stream().map(this::responseModule).toList()); + } + + private ValidatedRequest validateRequest(RnReleaseSetRequest request) { + if (request == null) throw new IllegalArgumentException("release-set request is required"); + String appKey = requireId(request.appKey(), "appKey"); + String target = requireId(request.targetModuleId(), "targetModuleId"); + RnBundleEntity.Platform platform; + try { + platform = RnBundleEntity.Platform.valueOf(requireText(request.platform(), "platform") + .toUpperCase(Locale.ROOT)); + } catch (Exception e) { + throw new IllegalArgumentException("platform must be ANDROID or IOS"); + } + String appVersion = requireText(request.appVersion(), "appVersion"); + RnVersionContract.parse(appVersion); + if (request.nativeApiLevel() == null || request.nativeApiLevel() < 1) { + throw new IllegalArgumentException("nativeApiLevel must be a positive integer"); + } + String nativeBaseline = requireText(request.nativeBaselineId(), "nativeBaselineId"); + List modules = request.installedModules(); + if (modules == null || modules.isEmpty()) { + throw new IllegalArgumentException("installedModules is required"); + } + Map installed = new LinkedHashMap<>(); + RnReleaseSetRequest.InstalledModule common = null; + for (RnReleaseSetRequest.InstalledModule module : modules) { + validateInstalled(module, appVersion, request.nativeApiLevel(), nativeBaseline); + if (installed.putIfAbsent(module.moduleId(), module) != null) { + throw new IllegalArgumentException("duplicate installed module: " + module.moduleId()); + } + if ("common".equals(module.type())) { + if (common != null) throw new IllegalArgumentException("exactly one common module is required"); + common = module; + } + } + if (common == null) throw new IllegalArgumentException("exactly one common module is required"); + RnReleaseSetRequest.InstalledModule installedTarget = installed.get(target); + if (installedTarget == null || !List.of("app", "buz").contains(installedTarget.type())) { + throw new IllegalArgumentException("targetModuleId must identify an installed app or buz module"); + } + String userId = request.userId() == null || request.userId().isBlank() + ? null : request.userId().trim(); + return new ValidatedRequest( + appKey, target, platform, userId, appVersion, request.nativeApiLevel(), + nativeBaseline, installed, common); + } + + private void validateInstalled( + RnReleaseSetRequest.InstalledModule module, + String appVersion, + int nativeApiLevel, + String nativeBaseline) { + if (module == null) throw new IllegalArgumentException("installed module must not be null"); + requireId(module.moduleId(), "installedModules.moduleId"); + String type = requireText(module.type(), "installedModules.type").toLowerCase(Locale.ROOT); + if (!List.of("common", "app", "buz").contains(type)) { + throw new IllegalArgumentException("installed module type is invalid"); + } + RnVersionContract.parse(requireText(module.version(), "installedModules.version")); + String appRange = requireText(module.appVersionRange(), "installedModules.appVersionRange"); + RnVersionContract.validateRange(appRange); + if (!RnVersionContract.satisfies(appVersion, appRange)) { + throw new IllegalArgumentException("installed module is incompatible with current appVersion"); + } + if (module.minNativeApiLevel() == null || module.minNativeApiLevel() < 1 + || module.minNativeApiLevel() > nativeApiLevel) { + throw new IllegalArgumentException("installed module is incompatible with nativeApiLevel"); + } + if (!nativeBaseline.equals(requireText( + module.builtAgainstNativeBaselineId(), + "installedModules.builtAgainstNativeBaselineId"))) { + throw new IllegalArgumentException("installed module native baseline mismatch"); + } + if ("common".equals(type)) { + if (module.commonVersionRange() != null && !module.commonVersionRange().isBlank()) { + throw new IllegalArgumentException("installed common must not declare commonVersionRange"); + } + } else { + RnVersionContract.validateRange(requireText( + module.commonVersionRange(), "installedModules.commonVersionRange")); + } + } + + private boolean runtimeCompatible(RnBundleEntity bundle, ValidatedRequest request) { + return RnVersionContract.satisfies(request.appVersion(), bundle.getAppVersionRange()) + && bundle.getMinNativeApiLevel() <= request.nativeApiLevel() + && request.nativeBaselineId().equals(bundle.getBuiltAgainstNativeBaselineId()); + } + + private boolean finalSetCompatible( + ValidatedRequest request, + RnBundleEntity targetUpgrade, + RnBundleEntity commonUpgrade) { + String commonVersion = commonUpgrade == null + ? request.common().version() : commonUpgrade.getVersion(); + for (RnReleaseSetRequest.InstalledModule module : request.installed().values()) { + if (module.moduleId().equals(request.common().moduleId())) continue; + String commonRange = module.moduleId().equals(request.targetModuleId()) + && targetUpgrade != null + ? targetUpgrade.getCommonVersionRange() + : module.commonVersionRange(); + if (!RnVersionContract.satisfies(commonVersion, commonRange)) return false; + } + return targetUpgrade == null + || RnVersionContract.satisfies(commonVersion, targetUpgrade.getCommonVersionRange()); + } + + private boolean eligibleForUser(RnBundleEntity bundle, String userId) { + if (!bundle.isGrayEnabled()) return true; + if (userId == null) return false; + return switch (bundle.getGrayMode()) { + case PERCENT -> Math.floorMod(userId.hashCode(), 100) < bundle.getGrayPercent(); + case MEMBERS -> memberIds(bundle.getGrayMemberIds()).contains(userId); + }; + } + + private List memberIds(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception ignored) { + return List.of(); + } + } + + private Comparator newestFirst() { + return (left, right) -> { + int version = RnVersionContract.parse(right.getVersion()) + .compareTo(RnVersionContract.parse(left.getVersion())); + if (version != 0) return version; + LocalDateTime leftTime = left.getCreatedAt() == null ? LocalDateTime.MIN : left.getCreatedAt(); + LocalDateTime rightTime = right.getCreatedAt() == null ? LocalDateTime.MIN : right.getCreatedAt(); + return rightTime.compareTo(leftTime); + }; + } + + private RnReleaseSetResponse.Module responseModule(RnBundleEntity bundle) { + String canonical = RnReleaseManifest.fromEntity(bundle).canonicalJson(objectMapper); + if (!Objects.equals(canonical, bundle.getSignedManifest())) { + throw new IllegalStateException("stored RN release identity no longer matches its signature"); + } + return new RnReleaseSetResponse.Module( + bundle.getAppKey(), + bundle.getModuleId(), + bundle.getModuleType().name().toLowerCase(Locale.ROOT), + bundle.getVersion(), + publicBaseUrl() + "/api/v1/rn/files/" + bundle.getId() + "/" + + bundle.getAppKey() + "/" + bundle.getPlatform().name().toLowerCase(Locale.ROOT) + + "/" + bundle.getModuleId(), + bundle.getArchiveSha256(), + bundle.getPackageName(), + bundle.getPlatform().name(), + bundle.getAppVersionRange(), + bundle.getBuiltAgainstNativeBaselineId(), + bundle.getBuildId(), + bundle.getBundleFormat(), + bundle.getCommonVersionRange(), + bundle.getMinNativeApiLevel(), + bundle.getBundleSha256(), + bundle.getSigningKeyId(), + bundle.getSignature(), + bundle.getSignedManifest(), + bundle.getNote() == null ? "" : bundle.getNote()); + } + + private String stableReleaseId(ValidatedRequest request, List selected) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update((request.appKey() + "\n" + request.platform() + "\n" + + request.targetModuleId() + "\n").getBytes(StandardCharsets.UTF_8)); + selected.stream() + .sorted(Comparator.comparing(RnBundleEntity::getModuleId)) + .forEach(bundle -> digest.update((bundle.getId() + "\n" + bundle.getVersion() + + "\n" + bundle.getArchiveSha256() + "\n" + bundle.getSigningKeyId() + + "\n" + bundle.getSignature() + "\n").getBytes(StandardCharsets.UTF_8))); + return "rnrs_" + HexFormat.of().formatHex(digest.digest()); + } catch (Exception e) { + throw new IllegalStateException("Failed to create RN releaseId", e); + } + } + + private String publicBaseUrl() { + String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + String suffix = "/api/v1/updates"; + return normalized.endsWith(suffix) + ? normalized.substring(0, normalized.length() - suffix.length()) + : normalized; + } + + private static RnBundleEntity.ModuleType parseType(String type) { + return RnBundleEntity.ModuleType.valueOf(type.toUpperCase(Locale.ROOT)); + } + + private static String requireId(String value, String field) { + String normalized = requireText(value, field); + if (!normalized.matches("[A-Za-z0-9._-]+")) { + throw new IllegalArgumentException(field + " is invalid"); + } + return normalized; + } + + private static String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return value.trim(); + } + + private record ValidatedRequest( + String appKey, + String targetModuleId, + RnBundleEntity.Platform platform, + String userId, + String appVersion, + int nativeApiLevel, + String nativeBaselineId, + Map installed, + RnReleaseSetRequest.InstalledModule common) { + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/RnVersionContract.java b/update-service/src/main/java/com/xuqm/update/service/RnVersionContract.java new file mode 100644 index 0000000..473a23f --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/RnVersionContract.java @@ -0,0 +1,69 @@ +package com.xuqm.update.service; + +import org.semver4j.Semver; +import org.semver4j.range.RangeList; +import org.semver4j.range.RangeListFactory; +import java.util.regex.Pattern; + +/** RN 与服务端共享 node-semver 语义的唯一 Java 适配点。 */ +public final class RnVersionContract { + + private static final Pattern RANGE_TOKEN = Pattern.compile( + "^(?:<=|>=|<|>|=|~|\\^)?" + + "(?:v?(?:0|[1-9]\\d*|x|X|\\*)" + + "(?:\\.(?:0|[1-9]\\d*|x|X|\\*)){0,2}" + + "(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?" + + "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?|\\*)$"); + + private RnVersionContract() { + } + + public static Semver parse(String version) { + if (version == null || version.isBlank()) { + throw new IllegalArgumentException("semantic version is required"); + } + return Semver.parse(version.trim()); + } + + public static void validateRange(String range) { + range(range); + } + + public static boolean satisfies(String version, String range) { + return range(range).isSatisfiedBy(parse(version)); + } + + private static RangeList range(String range) { + if (range == null || range.isBlank()) { + throw new IllegalArgumentException("semantic version range is required"); + } + String normalized = range.trim(); + String[] alternatives = normalized.split("\\|\\|", -1); + for (String alternative : alternatives) { + String branch = alternative.trim(); + if (branch.isEmpty()) { + throw new IllegalArgumentException("semantic version range has an empty branch"); + } + String[] tokens = branch.split("\\s+"); + for (int index = 0; index < tokens.length; index++) { + String token = tokens[index]; + if ("-".equals(token)) { + if (index == 0 || index == tokens.length - 1 + || !RANGE_TOKEN.matcher(tokens[index - 1]).matches() + || !RANGE_TOKEN.matcher(tokens[index + 1]).matches()) { + throw new IllegalArgumentException("invalid semantic version hyphen range"); + } + continue; + } + if (!RANGE_TOKEN.matcher(token).matches()) { + throw new IllegalArgumentException("invalid semantic version range token: " + token); + } + } + } + RangeList parsed = RangeListFactory.create(normalized, true); + if (parsed.get().isEmpty()) { + throw new IllegalArgumentException("semantic version range has no constraints"); + } + return parsed; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/TenantAppOwnershipClient.java b/update-service/src/main/java/com/xuqm/update/service/TenantAppOwnershipClient.java new file mode 100644 index 0000000..9008ea3 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/TenantAppOwnershipClient.java @@ -0,0 +1,56 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +/** 租户 JWT 对 appKey 的归属校验,依赖不可用时拒绝写操作。 */ +@Service +public class TenantAppOwnershipClient { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String tenantServiceUrl; + private final String internalToken; + + public TenantAppOwnershipClient( + RestTemplateBuilder builder, + ObjectMapper objectMapper, + @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") String tenantServiceUrl, + @Value("${sdk.internal-token:}") String internalToken) { + this.restTemplate = builder + .connectTimeout(Duration.ofMillis(500)) + .readTimeout(Duration.ofMillis(800)) + .build(); + this.objectMapper = objectMapper; + this.tenantServiceUrl = tenantServiceUrl; + this.internalToken = internalToken; + } + + public boolean owns(String tenantId, String appKey) { + if (!InternalServiceToken.isUsable(internalToken)) { + return false; + } + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Internal-Token", internalToken); + String body = restTemplate.exchange( + tenantServiceUrl + "/api/internal/sdk/apps/ownership/check", + HttpMethod.POST, + new HttpEntity<>(Map.of("tenantId", tenantId, "appKey", appKey), headers), + String.class).getBody(); + return objectMapper.readTree(body).path("data").path("owned").asBoolean(false); + } catch (Exception ignored) { + return false; + } + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/TenantReleaseManifestSigner.java b/update-service/src/main/java/com/xuqm/update/service/TenantReleaseManifestSigner.java new file mode 100644 index 0000000..1ea256d --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/TenantReleaseManifestSigner.java @@ -0,0 +1,72 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +/** + * 调用租户平台的唯一 Ed25519 私钥签署 RN 发布清单。 + * + *

内部令牌仅进入 Header;清单、令牌和服务端异常正文均不写日志。

+ */ +@Service +public class TenantReleaseManifestSigner { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String tenantServiceUrl; + private final String internalToken; + + public TenantReleaseManifestSigner( + RestTemplateBuilder builder, + ObjectMapper objectMapper, + @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") String tenantServiceUrl, + @Value("${sdk.internal-token:}") String internalToken) { + this.restTemplate = builder + .connectTimeout(Duration.ofMillis(500)) + .readTimeout(Duration.ofMillis(800)) + .build(); + this.objectMapper = objectMapper; + this.tenantServiceUrl = tenantServiceUrl; + this.internalToken = internalToken; + } + + public SignedPayload sign(String canonicalPayload) { + if (!InternalServiceToken.isUsable(internalToken)) { + throw new IllegalStateException( + "RN release manifest signing is unavailable: internal token is not configured"); + } + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Internal-Token", internalToken); + String body = restTemplate.exchange( + tenantServiceUrl + "/api/internal/sdk/release-manifest/sign", + HttpMethod.POST, + new HttpEntity<>(Map.of("canonicalPayload", canonicalPayload), headers), + String.class).getBody(); + JsonNode data = objectMapper.readTree(body).path("data"); + String keyId = data.path("keyId").asText(""); + String signature = data.path("signature").asText(""); + if (keyId.isBlank() || signature.isBlank()) { + throw new IllegalStateException("tenant signing service returned an invalid response"); + } + return new SignedPayload(keyId, signature); + } catch (Exception e) { + throw new IllegalStateException( + "RN release manifest signing is temporarily unavailable", e); + } + } + + public record SignedPayload(String keyId, String signature) { + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java index f5b8237..6a730ac 100644 --- a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java +++ b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.model.AppPackageInspectResult; -import com.xuqm.update.model.RnBundleInspectResult; import com.xuqm.update.repository.AppVersionRepository; import net.dongliu.apk.parser.ApkFile; import net.dongliu.apk.parser.bean.ApkMeta; @@ -163,54 +162,6 @@ public class UpdateAssetService { return downloadRemotePackage(packageUrl, false).path(); } - public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception { - String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null) - .orElse(""); - if (bundle == null || bundle.isEmpty()) { - return new RnBundleInspectResult(null, null, null, null, null, fileName, false); - } - if (isZipBundle(fileName)) { - Path temp = Files.createTempFile("xuqm-rn-bundle-inspect-", suffixFor(fileName)); - try { - try (InputStream in = bundle.getInputStream()) { - Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); - } - RnBundleInspectResult zipped = inspectRnBundleZip(temp, fileName); - if (zipped.detected()) { - return zipped; - } - } finally { - Files.deleteIfExists(temp); - } - } - return inspectRnBundleName(fileName); - } - - public StoredRnBundle storeRnBundle(String appKey, String platform, String moduleId, MultipartFile bundle) throws Exception { - if (bundle == null || bundle.isEmpty()) { - throw new IllegalArgumentException("bundle file is required"); - } - String filename = resolveBundleFilename(moduleId, platform, bundle.getOriginalFilename()); - Path dir = Paths.get(uploadDir, "rn", appKey, platform.toLowerCase(), moduleId); - Files.createDirectories(dir); - Path dest = dir.resolve(filename); - - String md5 = computeMd5(bundle); - bundle.transferTo(dest.toFile()); - return new StoredRnBundle(dest.toAbsolutePath().toString(), md5); - } - - private String computeMd5(MultipartFile file) throws Exception { - MessageDigest digest = MessageDigest.getInstance("MD5"); - try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) { - byte[] buf = new byte[8192]; - while (dis.read(buf) != -1) { - // read fully - } - } - return HexFormat.of().formatHex(digest.digest()); - } - private AppPackageInspectResult inspectApk(Path file, String fileName) throws Exception { try (ApkFile apk = new ApkFile(file.toFile())) { ApkMeta meta = apk.getApkMeta(); @@ -282,57 +233,6 @@ public class UpdateAssetService { return new AppPackageInspectResult("IOS", null, null, null, fileName, false); } - private RnBundleInspectResult inspectRnBundleName(String fileName) { - String baseName = stripExtension(fileName); - String[] parts = baseName.split("__"); - if (parts.length >= 4) { - return new RnBundleInspectResult( - blankToNull(parts[0]), - platformFromToken(parts[1]), - blankToNull(parts[2]), - blankToNull(parts[3]), - parts.length >= 5 ? blankToNull(parts[4]) : null, - fileName, - true); - } - return new RnBundleInspectResult( - null, - platformFromFileName(fileName), - null, - null, - null, - fileName, - false); - } - - private RnBundleInspectResult inspectRnBundleZip(Path file, String fileName) throws Exception { - try (ZipFile zipFile = new ZipFile(file.toFile())) { - ZipEntry entry = zipFile.getEntry("rn-manifest.json"); - if (entry == null) { - entry = zipFile.getEntry("manifest.json"); - } - if (entry != null) { - try (InputStream in = zipFile.getInputStream(entry)) { - JsonNode node = objectMapper.readTree(in); - String moduleId = text(node, "moduleId"); - String platform = text(node, "platform"); - String version = firstText(node, "bundleVersion", "version"); - String minCommonVersion = text(node, "minCommonVersion"); - String packageName = text(node, "packageName"); - return new RnBundleInspectResult( - moduleId, - platformFromToken(platform), - version, - minCommonVersion, - packageName, - fileName, - hasText(moduleId) && hasText(version) && hasText(platform)); - } - } - } - return inspectRnBundleName(fileName); - } - private Map parsePlistXml(String xml) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(false); @@ -369,49 +269,12 @@ public class UpdateAssetService { return "ANDROID"; } - private String platformFromToken(String token) { - if (token == null) { - return null; - } - String normalized = token.trim().toUpperCase(Locale.ROOT); - return switch (normalized) { - case "ANDROID", "IOS" -> normalized; - case "A", "ANDROIDSDK" -> "ANDROID"; - case "I", "IOSSDK" -> "IOS"; - default -> normalized.isBlank() ? null : normalized; - }; - } - - private String stripExtension(String fileName) { - if (fileName == null) return ""; - int idx = fileName.lastIndexOf('.'); - return idx > 0 ? fileName.substring(0, idx) : fileName; - } - private String suffixFor(String fileName) { if (fileName == null) return ".tmp"; int idx = fileName.lastIndexOf('.'); return idx > 0 ? fileName.substring(idx) : ".tmp"; } - private boolean isZipBundle(String fileName) { - String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT); - return lower.endsWith(".zip") || lower.endsWith(".bundle.zip") || lower.endsWith(".tar.gz"); - } - - private String resolveBundleFilename(String moduleId, String platform, String originalFilename) { - String safeOriginal = Optional.ofNullable(originalFilename).orElse("").trim(); - String ext = ""; - int idx = safeOriginal.lastIndexOf('.'); - if (idx > 0 && idx < safeOriginal.length() - 1) { - ext = safeOriginal.substring(idx); - } - if (!hasText(ext)) { - ext = ".zip"; - } - return moduleId + "." + platform.toLowerCase(Locale.ROOT) + ext; - } - /** * Rewrite external file-service URLs to the internal Docker network address to avoid * hairpin NAT / HTTPS overhead when update-service and file-service are co-located. @@ -630,19 +493,6 @@ public class UpdateAssetService { return value == null || value.isBlank() ? null : value.trim(); } - private String text(JsonNode node, String field) { - if (node == null || !node.hasNonNull(field)) { - return null; - } - String value = node.get(field).asText(); - return blankToNull(value); - } - - private String firstText(JsonNode node, String primaryField, String fallbackField) { - String primary = text(node, primaryField); - return hasText(primary) ? primary : text(node, fallbackField); - } - private AppPackageInspectResult fallbackInspectResult(String source) { String fileName = source; try { @@ -667,7 +517,5 @@ public class UpdateAssetService { return value != null && !value.isBlank(); } - public record StoredRnBundle(String bundlePath, String md5) {} - private record RemotePackage(Path path, String fileName, String contentType) {} } diff --git a/update-service/src/main/resources/application.yml b/update-service/src/main/resources/application.yml index 46aa6c3..9d8aa5b 100644 --- a/update-service/src/main/resources/application.yml +++ b/update-service/src/main/resources/application.yml @@ -36,7 +36,7 @@ update: sdk: tenant-service-url: ${SDK_TENANT_SERVICE_URL:http://xuqm-tenant-service:9001} - internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} + internal-token: ${SDK_INTERNAL_TOKEN:} management: endpoints: diff --git a/update-service/src/main/resources/db/migration/V3__rn_release_set_terminal_contract.sql b/update-service/src/main/resources/db/migration/V3__rn_release_set_terminal_contract.sql new file mode 100644 index 0000000..c5fc499 --- /dev/null +++ b/update-service/src/main/resources/db/migration/V3__rn_release_set_terminal_contract.sql @@ -0,0 +1,23 @@ +ALTER TABLE update_rn_bundle + DROP COLUMN md5, + DROP COLUMN min_common_version, + ADD COLUMN module_type VARCHAR(16) NULL AFTER module_id, + MODIFY COLUMN version VARCHAR(64) NOT NULL, + ADD COLUMN app_version_range VARCHAR(128) NULL AFTER version, + ADD COLUMN built_against_native_baseline_id VARCHAR(128) NULL AFTER app_version_range, + ADD COLUMN build_id VARCHAR(128) NULL AFTER built_against_native_baseline_id, + ADD COLUMN bundle_format VARCHAR(32) NULL AFTER build_id, + ADD COLUMN common_version_range VARCHAR(128) NULL AFTER bundle_format, + ADD COLUMN min_native_api_level INT NULL AFTER common_version_range, + ADD COLUMN bundle_sha256 VARCHAR(64) NULL AFTER min_native_api_level, + ADD COLUMN archive_sha256 VARCHAR(64) NULL AFTER bundle_sha256, + ADD COLUMN signed_manifest TEXT NULL AFTER bundle_url, + ADD COLUMN signing_key_id VARCHAR(64) NULL AFTER signed_manifest, + ADD COLUMN signature VARCHAR(256) NULL AFTER signing_key_id, + ADD INDEX idx_rn_release_candidates + (app_key, platform, module_id, publish_status, created_at); + +-- V3 前的记录没有不可变 manifest、ZIP SHA-256 与平台签名,明确失效并要求重新上传。 +UPDATE update_rn_bundle +SET publish_status = 'DEPRECATED' +WHERE archive_sha256 IS NULL OR signed_manifest IS NULL OR signature IS NULL; diff --git a/update-service/src/test/java/com/xuqm/update/config/RnBearerAuthFilterTest.java b/update-service/src/test/java/com/xuqm/update/config/RnBearerAuthFilterTest.java new file mode 100644 index 0000000..8bdb2b2 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/config/RnBearerAuthFilterTest.java @@ -0,0 +1,42 @@ +package com.xuqm.update.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.xuqm.common.security.ApiKeyAuthFilter; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +class RnBearerAuthFilterTest { + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void ignoresXApiKeyAndBindsValidBearerToOwningApp() throws Exception { + RnBearerAuthFilter filter = new RnBearerAuthFilter( + token -> "valid-token".equals(token) ? "ak_owned" : null); + MockHttpServletRequest xOnly = request(); + xOnly.addHeader(ApiKeyAuthFilter.HEADER_NAME, "valid-token"); + filter.doFilter(xOnly, new MockHttpServletResponse(), (request, response) -> {}); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + + MockHttpServletRequest bearer = request(); + bearer.addHeader("Authorization", "Bearer valid-token"); + AtomicBoolean called = new AtomicBoolean(); + filter.doFilter(bearer, new MockHttpServletResponse(), + (request, response) -> called.set(true)); + assertThat(called).isTrue(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getName()) + .isEqualTo("apikey:ak_owned"); + } + + private MockHttpServletRequest request() { + return new MockHttpServletRequest("POST", "/api/v1/rn/upload"); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/controller/RnBundleControllerIdentityTest.java b/update-service/src/test/java/com/xuqm/update/controller/RnBundleControllerIdentityTest.java new file mode 100644 index 0000000..1d35c12 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/controller/RnBundleControllerIdentityTest.java @@ -0,0 +1,21 @@ +package com.xuqm.update.controller; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class RnBundleControllerIdentityTest { + + @Test + void optionalRequestIdentityMustEqualZipManifestIdentity() { + assertThatCode(() -> RnBundleController.requireEqual( + null, "miniapp", "moduleId")).doesNotThrowAnyException(); + assertThatCode(() -> RnBundleController.requireEqual( + "miniapp", "miniapp", "moduleId")).doesNotThrowAnyException(); + assertThatThrownBy(() -> RnBundleController.requireEqual( + "forged-module", "miniapp", "moduleId")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rn-manifest.json"); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/controller/UpdateFileControllerTest.java b/update-service/src/test/java/com/xuqm/update/controller/UpdateFileControllerTest.java new file mode 100644 index 0000000..6554fbb --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/controller/UpdateFileControllerTest.java @@ -0,0 +1,65 @@ +package com.xuqm.update.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.xuqm.update.entity.RnBundleEntity; +import com.xuqm.update.repository.RnBundleRepository; +import com.xuqm.update.service.RnBundlePackageService; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class UpdateFileControllerTest { + + @TempDir + Path tempDirectory; + + @Test + void downloadRequiresExactRecordAppPlatformAndModuleIdentity() throws Exception { + RnBundleRepository repository = mock(RnBundleRepository.class); + RnBundlePackageService packageService = mock(RnBundlePackageService.class); + UpdateFileController controller = new UpdateFileController(repository, packageService); + RnBundleEntity entity = terminalEntity(); + Path file = Files.writeString(tempDirectory.resolve("bundle.xuqm.zip"), "zip"); + when(repository.findById("bundle-1")).thenReturn(Optional.of(entity)); + when(packageService.verifyStoredFile(entity)).thenReturn(file); + + assertThat(controller.downloadRnBundle( + "bundle-1", "ak_other", "android", "miniapp").getStatusCode().value()) + .isEqualTo(404); + assertThat(controller.downloadRnBundle( + "bundle-1", "ak_app", "android", "miniapp").getStatusCode().value()) + .isEqualTo(200); + verify(packageService).verifyStoredFile(entity); + } + + private RnBundleEntity terminalEntity() { + RnBundleEntity entity = new RnBundleEntity(); + entity.setId("bundle-1"); + entity.setAppKey("ak_app"); + entity.setPackageName("com.example"); + entity.setModuleId("miniapp"); + entity.setModuleType(RnBundleEntity.ModuleType.BUZ); + entity.setPlatform(RnBundleEntity.Platform.ANDROID); + entity.setVersion("1.0.0"); + entity.setAppVersionRange(">=8.0.0 <9.0.0"); + entity.setBuiltAgainstNativeBaselineId("android-sha256:base"); + entity.setBuildId("build-1"); + entity.setBundleFormat("hermes-bytecode"); + entity.setCommonVersionRange(">=1.0.0 <2.0.0"); + entity.setMinNativeApiLevel(2); + entity.setBundleSha256("a".repeat(64)); + entity.setArchiveSha256("b".repeat(64)); + entity.setBundleUrl("/tmp/bundle.zip"); + entity.setSignedManifest("{}"); + entity.setSigningKeyId("key"); + entity.setSignature("signature"); + entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); + return entity; + } +} diff --git a/update-service/src/test/java/com/xuqm/update/model/RnReleaseManifestVectorTest.java b/update-service/src/test/java/com/xuqm/update/model/RnReleaseManifestVectorTest.java new file mode 100644 index 0000000..848d5de --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/model/RnReleaseManifestVectorTest.java @@ -0,0 +1,65 @@ +package com.xuqm.update.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class RnReleaseManifestVectorTest { + + /** RFC 8410 Ed25519 SubjectPublicKeyInfo 固定前缀;跨端向量只存储协议使用的原始 32 字节公钥。 */ + private static final byte[] ED25519_X509_PREFIX = new byte[] { + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00 + }; + + @Test + void canonicalizesUtf8SortedKeysAndOmitsNullLikeRnVector() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + var vector = mapper.readTree( + getClass().getResourceAsStream("/rn-release-signature-vector.json")); + RnReleaseManifest manifest = new RnReleaseManifest( + "ak_vector", + "1".repeat(64), + "com.xuqm.vector", + "ANDROID", + "common", + "common", + "1.2.3", + ">=8.0.0 <9.0.0", + "android-sha256:abc", + "build-42", + "hermes-bytecode", + null, + 2, + "0".repeat(64)); + + String canonical = manifest.canonicalJson(mapper); + assertThat(canonical).isEqualTo(vector.path("canonicalJson").asText()); + assertThat(canonical).doesNotContain("commonVersionRange"); + + byte[] rawPublicKey = + Base64.getDecoder().decode(vector.path("publicKeyRawBase64").asText()); + assertThat(rawPublicKey).hasSize(32); + byte[] x509PublicKey = new byte[ED25519_X509_PREFIX.length + rawPublicKey.length]; + System.arraycopy( + ED25519_X509_PREFIX, 0, x509PublicKey, 0, ED25519_X509_PREFIX.length); + System.arraycopy( + rawPublicKey, + 0, + x509PublicKey, + ED25519_X509_PREFIX.length, + rawPublicKey.length); + var publicKey = KeyFactory.getInstance("Ed25519") + .generatePublic(new X509EncodedKeySpec(x509PublicKey)); + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(canonical.getBytes(StandardCharsets.UTF_8)); + assertThat(verifier.verify(Base64.getUrlDecoder().decode( + vector.path("signatureBase64Url").asText()))).isTrue(); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/service/InternalRnClientTokenTest.java b/update-service/src/test/java/com/xuqm/update/service/InternalRnClientTokenTest.java new file mode 100644 index 0000000..dc67f05 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/service/InternalRnClientTokenTest.java @@ -0,0 +1,37 @@ +package com.xuqm.update.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.client.RestTemplateBuilder; + +class InternalRnClientTokenTest { + + @Test + void ownershipFailsClosedBeforeNetworkWhenTokenIsMissingOrPlaceholder() { + assertThat(ownershipClient("").owns("tenant", "ak_test")).isFalse(); + assertThat(ownershipClient("xuqm-internal-token").owns("tenant", "ak_test")).isFalse(); + } + + @Test + void signingFailsClosedBeforeNetworkWhenTokenIsMissingOrPlaceholder() { + assertThatThrownBy(() -> signer("").sign("{}")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not configured"); + assertThatThrownBy(() -> signer("xuqm-internal-token").sign("{}")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not configured"); + } + + private TenantAppOwnershipClient ownershipClient(String token) { + return new TenantAppOwnershipClient( + new RestTemplateBuilder(), new ObjectMapper(), "http://127.0.0.1:1", token); + } + + private TenantReleaseManifestSigner signer(String token) { + return new TenantReleaseManifestSigner( + new RestTemplateBuilder(), new ObjectMapper(), "http://127.0.0.1:1", token); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/service/RnBundleAuthorizationServiceTest.java b/update-service/src/test/java/com/xuqm/update/service/RnBundleAuthorizationServiceTest.java new file mode 100644 index 0000000..e168f13 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/service/RnBundleAuthorizationServiceTest.java @@ -0,0 +1,26 @@ +package com.xuqm.update.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +class RnBundleAuthorizationServiceTest { + + @Test + void apiTokenCanOnlyWriteItsOwnApp() { + RnBundleAuthorizationService service = + new RnBundleAuthorizationService(mock(TenantAppOwnershipClient.class)); + var authentication = new UsernamePasswordAuthenticationToken( + "apikey:ak_one", null, List.of(new SimpleGrantedAuthority("ROLE_API_KEY"))); + + assertThatCode(() -> service.requireOwned(authentication, "ak_one")).doesNotThrowAnyException(); + assertThatThrownBy(() -> service.requireOwned(authentication, "ak_two")) + .isInstanceOf(AccessDeniedException.class); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/service/RnBundlePackageServiceTest.java b/update-service/src/test/java/com/xuqm/update/service/RnBundlePackageServiceTest.java new file mode 100644 index 0000000..f1f17df --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/service/RnBundlePackageServiceTest.java @@ -0,0 +1,278 @@ +package com.xuqm.update.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.apache.commons.compress.archivers.zip.UnixStat; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +class RnBundlePackageServiceTest { + + @TempDir + Path tempDirectory; + + @Test + void keepsCrossPlatformArchiveSafetyLimitsStable() { + assertThat(RnBundlePackageService.MAX_ARCHIVE_BYTES).isEqualTo(50L * 1024 * 1024); + assertThat(RnBundlePackageService.MAX_ENTRY_BYTES).isEqualTo(100L * 1024 * 1024); + assertThat(RnBundlePackageService.MAX_TOTAL_UNCOMPRESSED_BYTES) + .isEqualTo(100L * 1024 * 1024); + assertThat(RnBundlePackageService.MAX_ENTRY_COUNT).isEqualTo(4096); + } + + @Test + void readsIdentityOnlyFromRootManifestAndRecomputesBundleSha256() throws Exception { + byte[] bundle = "compiled bundle".getBytes(StandardCharsets.UTF_8); + RnBundlePackageService service = + new RnBundlePackageService(new ObjectMapper(), tempDirectory.toString()); + + var inspected = service.inspect(new MockMultipartFile( + "bundle", "misleading__ios__9.9.9.zip", "application/zip", + packageBytes(bundle, sha256(bundle), true))); + + assertThat(inspected.moduleId()).isEqualTo("miniapp"); + assertThat(inspected.platform().name()).isEqualTo("ANDROID"); + assertThat(inspected.version()).isEqualTo("1.2.3"); + assertThat(inspected.bundleSha256()).isEqualTo(sha256(bundle)); + assertThat(inspected.archiveSha256()).matches("[a-f0-9]{64}"); + service.deleteTemporary(inspected); + } + + @Test + void rejectsTamperedBundleAndMissingRootManifest() throws Exception { + byte[] bundle = "tampered".getBytes(StandardCharsets.UTF_8); + RnBundlePackageService service = + new RnBundlePackageService(new ObjectMapper(), tempDirectory.toString()); + + assertThatThrownBy(() -> service.inspect(new MockMultipartFile( + "bundle", "miniapp.xuqm.zip", "application/zip", + packageBytes(bundle, "0".repeat(64), true)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not match"); + assertThatThrownBy(() -> service.inspect(new MockMultipartFile( + "bundle", "miniapp.xuqm.zip", "application/zip", + packageBytes(bundle, sha256(bundle), false)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rn-manifest.json"); + } + + @Test + void rejectsUnknownAndDuplicateManifestFields() throws Exception { + byte[] bundle = "compiled bundle".getBytes(StandardCharsets.UTF_8); + RnBundlePackageService service = + new RnBundlePackageService(new ObjectMapper(), tempDirectory.toString()); + String valid = manifest(bundle, sha256(bundle)); + + assertThatThrownBy(() -> inspect(service, zip( + valid.replace("\"assets\": []", "\"unknownField\": true, \"assets\": []"), + bundle))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fields"); + assertThatThrownBy(() -> inspect(service, zip( + valid.replace( + "\"moduleId\": \"miniapp\"", + "\"moduleId\": \"miniapp\", \"moduleId\": \"forged\""), + bundle))) + .isInstanceOf(Exception.class) + .hasMessageContaining("Duplicate field"); + } + + @Test + void rejectsUndeclaredAssetUnsafePathSymlinkAndExcessiveEntryCount() throws Exception { + byte[] bundle = "compiled bundle".getBytes(StandardCharsets.UTF_8); + RnBundlePackageService service = + new RnBundlePackageService(new ObjectMapper(), tempDirectory.toString()); + String valid = manifest(bundle, sha256(bundle)); + + assertThatThrownBy(() -> inspect(service, zip(valid, bundle, "assets/undeclared.png"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("undeclared"); + assertThatThrownBy(() -> inspect(service, zip(valid, bundle, "assets/../escape.png"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unsafe"); + assertThatThrownBy(() -> inspect(service, symlinkZip(valid, bundle))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("symbolic"); + assertThatThrownBy(() -> inspect(service, zipWithExtraEntries(valid, bundle, 4095))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("too many entries"); + } + + @Test + void verifiesEveryDeclaredAssetLengthAndSha256() throws Exception { + byte[] bundle = "compiled bundle".getBytes(StandardCharsets.UTF_8); + byte[] asset = "asset bytes".getBytes(StandardCharsets.UTF_8); + RnBundlePackageService service = + new RnBundlePackageService(new ObjectMapper(), tempDirectory.toString()); + String withAsset = manifest(bundle, sha256(bundle)).replace( + "\"assets\": []", + "\"assets\": [{\"path\":\"icon.png\",\"byteLength\":" + + asset.length + ",\"sha256\":\"" + sha256(asset) + "\"}]"); + + var inspected = service.inspect(new MockMultipartFile( + "bundle", "miniapp.xuqm.zip", "application/zip", + zip(withAsset, bundle, "assets/icon.png", asset))); + service.deleteTemporary(inspected); + + assertThatThrownBy(() -> inspect( + service, + zip(withAsset, bundle, "assets/icon.png", + "tampered".getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("asset does not match"); + } + + @Test + void acceptsCliSchemaV1FixtureWithDeclaredAssets() throws Exception { + byte[] bundle = "compiled bundle".getBytes(StandardCharsets.UTF_8); + byte[] asset = "asset bytes".getBytes(StandardCharsets.UTF_8); + String manifest; + try (InputStream input = getClass().getResourceAsStream("/rn-package-manifest-v1.json")) { + manifest = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + RnBundlePackageService service = + new RnBundlePackageService(new ObjectMapper(), tempDirectory.toString()); + + var inspected = service.inspect(new MockMultipartFile( + "bundle", "miniapp.android.xuqm.zip", "application/zip", + zip(manifest, bundle, "assets/icon.png", asset))); + + assertThat(inspected.packageName()).isEqualTo("cn.org.bjca.wcert.ywq"); + assertThat(inspected.bundleSha256()).isEqualTo(sha256(bundle)); + service.deleteTemporary(inspected); + } + + private byte[] packageBytes(byte[] bundle, String declaredSha, boolean rootManifest) + throws Exception { + String manifest = manifest(bundle, declaredSha); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry(rootManifest ? "rn-manifest.json" : "nested/rn-manifest.json")); + zip.write(manifest.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("miniapp.android.bundle")); + zip.write(bundle); + zip.closeEntry(); + } + return output.toByteArray(); + } + + private String manifest(byte[] bundle, String declaredSha) { + return """ + { + "schemaVersion": 1, + "packageName": "cn.org.bjca.wcert.ywq", + "moduleId": "miniapp", + "type": "buz", + "platform": "android", + "version": "1.2.3", + "appVersion": "8.0.0", + "appVersionRange": ">=8.0.0 <9.0.0", + "builtAgainstNativeBaselineId": "android-sha256:abc", + "buildId": "build-42", + "bundleFormat": "hermes-bytecode", + "bugCollectMode": "platform", + "commonVersionRange": ">=1.0.0 <2.0.0", + "minNativeApiLevel": 2, + "bundleFile": "miniapp.android.bundle", + "bundleByteLength": %d, + "bundleSha256": "%s", + "assets": [] + } + """.formatted(bundle.length, declaredSha); + } + + private byte[] zip(String manifest, byte[] bundle, String... emptyEntries) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry("rn-manifest.json")); + zip.write(manifest.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("miniapp.android.bundle")); + zip.write(bundle); + zip.closeEntry(); + for (String entry : emptyEntries) { + zip.putNextEntry(new ZipEntry(entry)); + zip.closeEntry(); + } + } + return output.toByteArray(); + } + + private byte[] zip( + String manifest, byte[] bundle, String assetPath, byte[] assetBytes) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry("rn-manifest.json")); + zip.write(manifest.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("miniapp.android.bundle")); + zip.write(bundle); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry(assetPath)); + zip.write(assetBytes); + zip.closeEntry(); + } + return output.toByteArray(); + } + + private byte[] symlinkZip(String manifest, byte[] bundle) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipArchiveOutputStream zip = new ZipArchiveOutputStream(output)) { + ZipArchiveEntry manifestEntry = new ZipArchiveEntry("rn-manifest.json"); + zip.putArchiveEntry(manifestEntry); + zip.write(manifest.getBytes(StandardCharsets.UTF_8)); + zip.closeArchiveEntry(); + ZipArchiveEntry bundleEntry = new ZipArchiveEntry("miniapp.android.bundle"); + zip.putArchiveEntry(bundleEntry); + zip.write(bundle); + zip.closeArchiveEntry(); + ZipArchiveEntry link = new ZipArchiveEntry("assets/link"); + link.setUnixMode(UnixStat.LINK_FLAG | 0777); + zip.putArchiveEntry(link); + zip.write("../outside".getBytes(StandardCharsets.UTF_8)); + zip.closeArchiveEntry(); + } + return output.toByteArray(); + } + + private byte[] zipWithExtraEntries( + String manifest, byte[] bundle, int extraEntryCount) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry("rn-manifest.json")); + zip.write(manifest.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("miniapp.android.bundle")); + zip.write(bundle); + zip.closeEntry(); + for (int i = 0; i < extraEntryCount; i++) { + zip.putNextEntry(new ZipEntry("assets/extra-" + i)); + zip.closeEntry(); + } + } + return output.toByteArray(); + } + + private void inspect(RnBundlePackageService service, byte[] bytes) throws Exception { + service.inspect(new MockMultipartFile( + "bundle", "miniapp.xuqm.zip", "application/zip", bytes)); + } + + private static String sha256(byte[] value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/service/RnReleaseSetServiceTest.java b/update-service/src/test/java/com/xuqm/update/service/RnReleaseSetServiceTest.java new file mode 100644 index 0000000..6cc83bb --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/service/RnReleaseSetServiceTest.java @@ -0,0 +1,161 @@ +package com.xuqm.update.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.RnBundleEntity; +import com.xuqm.update.model.RnReleaseManifest; +import com.xuqm.update.model.RnReleaseSetRequest; +import com.xuqm.update.repository.RnBundleRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RnReleaseSetServiceTest { + + private RnBundleRepository repository; + private PublishConfigService publishConfig; + private ObjectMapper mapper; + private RnReleaseSetService service; + + @BeforeEach + void setUp() { + repository = mock(RnBundleRepository.class); + publishConfig = mock(PublishConfigService.class); + mapper = new ObjectMapper(); + service = new RnReleaseSetService( + repository, publishConfig, mapper, "https://update.example/api/v1/updates"); + } + + @Test + void returnsOnlyCompatibleTargetAndCommonClosureWithoutUnrelatedModules() { + when(publishConfig.allowAnonymousUpdateCheck("ak_app")).thenReturn(true); + when(repository.findByAppKeyAndPlatformAndPublishStatusOrderByCreatedAtDesc( + "ak_app", RnBundleEntity.Platform.ANDROID, RnBundleEntity.PublishStatus.PUBLISHED)) + .thenReturn(List.of( + release("other", RnBundleEntity.ModuleType.BUZ, "9.0.0", ">=1.0.0 <2.0.0"), + release("miniapp", RnBundleEntity.ModuleType.BUZ, "1.2.0", ">=1.1.0 <2.0.0"), + release("other-common", RnBundleEntity.ModuleType.COMMON, "9.0.0", null), + release("common", RnBundleEntity.ModuleType.COMMON, "1.1.0", null))); + + var result = service.check(request(null, ">=1.0.0 <2.0.0")); + + assertThat(result.releaseId()).startsWith("rnrs_"); + assertThat(result.modules()).extracting(module -> module.moduleId()) + .containsExactly("common", "miniapp"); + assertThat(result.modules()).allSatisfy(module -> { + assertThat(module.appKey()).isEqualTo("ak_app"); + assertThat(module.sha256()).hasSize(64); + assertThat(module.keyId()).isEqualTo("key-1"); + assertThat(module.signedManifest()).isNotBlank(); + }); + } + + @Test + void excludesCommonThatWouldBreakAnotherInstalledModuleAndRejectsWrongBaseline() { + when(publishConfig.allowAnonymousUpdateCheck("ak_app")).thenReturn(true); + RnBundleEntity target = release( + "miniapp", RnBundleEntity.ModuleType.BUZ, "1.2.0", ">=1.1.0 <2.0.0"); + RnBundleEntity common = release("common", RnBundleEntity.ModuleType.COMMON, "1.1.0", null); + common.setBuiltAgainstNativeBaselineId("android-sha256:other"); + common.setSignedManifest(RnReleaseManifest.fromEntity(common).canonicalJson(mapper)); + when(repository.findByAppKeyAndPlatformAndPublishStatusOrderByCreatedAtDesc( + "ak_app", RnBundleEntity.Platform.ANDROID, RnBundleEntity.PublishStatus.PUBLISHED)) + .thenReturn(List.of(target, common)); + + var result = service.check(request("user-1", ">=1.0.0 <1.1.0")); + + assertThat(result.modules()).isEmpty(); + assertThat(result.releaseId()).isNull(); + } + + @Test + void requiresLoginAndUsesUserIdForGrayMembership() { + when(publishConfig.allowAnonymousUpdateCheck("ak_app")).thenReturn(false); + assertThat(service.check(request(null, ">=1.0.0 <2.0.0")).modules()).isEmpty(); + verifyNoInteractions(repository); + + RnBundleEntity target = + release("miniapp", RnBundleEntity.ModuleType.BUZ, "1.1.0", ">=1.0.0 <2.0.0"); + target.setGrayEnabled(true); + target.setGrayMode(RnBundleEntity.GrayMode.MEMBERS); + target.setGrayMemberIds("[\"user-allowed\"]"); + when(repository.findByAppKeyAndPlatformAndPublishStatusOrderByCreatedAtDesc( + "ak_app", RnBundleEntity.Platform.ANDROID, RnBundleEntity.PublishStatus.PUBLISHED)) + .thenReturn(List.of(target)); + + assertThat(service.check(request("user-denied", ">=1.0.0 <2.0.0")).modules()).isEmpty(); + assertThat(service.check(request("user-allowed", ">=1.0.0 <2.0.0")).modules()) + .extracting(module -> module.moduleId()).containsExactly("miniapp"); + } + + @Test + void ignoresLegacyPublishedRowsWithNoSignedTerminalIdentity() { + when(publishConfig.allowAnonymousUpdateCheck("ak_app")).thenReturn(true); + RnBundleEntity legacy = new RnBundleEntity(); + legacy.setAppKey("ak_app"); + legacy.setModuleId("miniapp"); + legacy.setPlatform(RnBundleEntity.Platform.ANDROID); + legacy.setVersion("99.0.0"); + legacy.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); + when(repository.findByAppKeyAndPlatformAndPublishStatusOrderByCreatedAtDesc( + "ak_app", RnBundleEntity.Platform.ANDROID, RnBundleEntity.PublishStatus.PUBLISHED)) + .thenReturn(List.of(legacy)); + + assertThat(service.check(request(null, ">=1.0.0 <2.0.0")).modules()).isEmpty(); + } + + private RnReleaseSetRequest request(String userId, String otherCommonRange) { + return new RnReleaseSetRequest( + "ak_app", + "miniapp", + "ANDROID", + userId, + "8.0.0", + 2, + "android-sha256:base", + List.of( + installed("common", "common", "1.0.0", null), + installed("miniapp", "buz", "1.0.0", ">=1.0.0 <2.0.0"), + installed("other", "buz", "1.0.0", otherCommonRange))); + } + + private RnReleaseSetRequest.InstalledModule installed( + String moduleId, String type, String version, String commonRange) { + return new RnReleaseSetRequest.InstalledModule( + moduleId, type, version, ">=8.0.0 <9.0.0", + "android-sha256:base", commonRange, 2); + } + + private RnBundleEntity release( + String moduleId, RnBundleEntity.ModuleType type, String version, String commonRange) { + RnBundleEntity entity = new RnBundleEntity(); + entity.setId(moduleId + "-" + version); + entity.setAppKey("ak_app"); + entity.setPackageName("cn.org.bjca.wcert.ywq"); + entity.setModuleId(moduleId); + entity.setModuleType(type); + entity.setPlatform(RnBundleEntity.Platform.ANDROID); + entity.setVersion(version); + entity.setAppVersionRange(">=8.0.0 <9.0.0"); + entity.setBuiltAgainstNativeBaselineId("android-sha256:base"); + entity.setBuildId("build-" + version); + entity.setBundleFormat("hermes-bytecode"); + entity.setCommonVersionRange(commonRange); + entity.setMinNativeApiLevel(2); + entity.setBundleSha256("a".repeat(64)); + entity.setArchiveSha256("b".repeat(64)); + entity.setBundleUrl("/tmp/" + moduleId + ".zip"); + entity.setSigningKeyId("key-1"); + entity.setSignature("signature"); + entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); + entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); + entity.setCreatedAt(LocalDateTime.now()); + entity.setSignedManifest(RnReleaseManifest.fromEntity(entity).canonicalJson(mapper)); + return entity; + } +} diff --git a/update-service/src/test/java/com/xuqm/update/service/RnVersionContractTest.java b/update-service/src/test/java/com/xuqm/update/service/RnVersionContractTest.java new file mode 100644 index 0000000..b926bb5 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/service/RnVersionContractTest.java @@ -0,0 +1,26 @@ +package com.xuqm.update.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +class RnVersionContractTest { + + @Test + void matchesSharedNodeSemverVectorAndRejectsInvalidTrailingBranches() throws Exception { + var vector = new ObjectMapper().readTree( + getClass().getResourceAsStream("/rn-semver-vector.json")); + for (var item : vector.path("valid")) { + assertThat(RnVersionContract.satisfies( + item.path("version").asText(), item.path("range").asText())) + .as("%s in %s", item.path("version").asText(), item.path("range").asText()) + .isEqualTo(item.path("satisfies").asBoolean()); + } + for (var range : vector.path("invalidRanges")) { + assertThatThrownBy(() -> RnVersionContract.validateRange(range.asText())) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/update-service/src/test/resources/rn-package-manifest-v1.json b/update-service/src/test/resources/rn-package-manifest-v1.json new file mode 100644 index 0000000..ab0fe43 --- /dev/null +++ b/update-service/src/test/resources/rn-package-manifest-v1.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "packageName": "cn.org.bjca.wcert.ywq", + "moduleId": "miniapp", + "type": "buz", + "platform": "android", + "version": "1.2.3", + "appVersion": "8.0.0", + "appVersionRange": ">=8.0.0 <9.0.0", + "builtAgainstNativeBaselineId": "android-sha256:abc", + "bundleFormat": "hermes-bytecode", + "buildId": "build-42", + "bugCollectMode": "platform", + "bundleFile": "miniapp.android.bundle", + "bundleByteLength": 15, + "bundleSha256": "45c37655759f7b41dcb8b2991c25fe18b156fe379c9b0943cbdf2c68733ea8b8", + "commonVersionRange": ">=1.0.0 <2.0.0", + "minNativeApiLevel": 2, + "assets": [ + { + "path": "icon.png", + "byteLength": 11, + "sha256": "84293ed06cb3210e7d549afec3140d0c48494416ad25b7f25196afffaa5eb796" + } + ] +} diff --git a/update-service/src/test/resources/rn-release-signature-vector.json b/update-service/src/test/resources/rn-release-signature-vector.json new file mode 100644 index 0000000..48d8a2f --- /dev/null +++ b/update-service/src/test/resources/rn-release-signature-vector.json @@ -0,0 +1,6 @@ +{ + "keyId": "xuqm-rn-vector-2026-07", + "publicKeyRawBase64": "jkw/1R5aVg2NAwB7+xw2fR9i8phXi+2jt9l8QzuG8pU=", + "canonicalJson": "{\"appKey\":\"ak_vector\",\"appVersionRange\":\">=8.0.0 <9.0.0\",\"archiveSha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"buildId\":\"build-42\",\"builtAgainstNativeBaselineId\":\"android-sha256:abc\",\"bundleFormat\":\"hermes-bytecode\",\"bundleSha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"minNativeApiLevel\":2,\"moduleId\":\"common\",\"packageName\":\"com.xuqm.vector\",\"platform\":\"ANDROID\",\"type\":\"common\",\"version\":\"1.2.3\"}", + "signatureBase64Url": "p5GHekcL-wqM9yC7xNGRLMEwNVy7ECwVGwIdWkMJ9G85Pgr4XXca3kBXm3G-hLB0XZa76_tVmBNJhHkUuse6AQ" +} diff --git a/update-service/src/test/resources/rn-semver-vector.json b/update-service/src/test/resources/rn-semver-vector.json new file mode 100644 index 0000000..cc110f5 --- /dev/null +++ b/update-service/src/test/resources/rn-semver-vector.json @@ -0,0 +1,11 @@ +{ + "valid": [ + {"version": "1.5.0", "range": ">=1.0.0 <2.0.0", "satisfies": true}, + {"version": "2.0.0", "range": ">=1.0.0 <2.0.0", "satisfies": false}, + {"version": "1.3.0", "range": "^1.2.3", "satisfies": true}, + {"version": "1.2.5", "range": "~1.2.3", "satisfies": true}, + {"version": "1.2.3-beta.2", "range": ">=1.2.3-beta.1 <1.2.3", "satisfies": true}, + {"version": "2.4.1", "range": "1.x || >=2.4.0 <3.0.0", "satisfies": true} + ], + "invalidRanges": ["* || garbage", ">=1.0.0 ||", "not-semver"] +}